Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cob: Add support for media embeds in COBs
cloudhead committed 2 years ago
commit 534503d002c45f8d5a74a995e64a76f49d3f363c
parent f282e09dce21afc1655496bdf25b08a5f568697e
31 files changed +545 -101
modified Cargo.lock
@@ -1924,6 +1924,7 @@ dependencies = [
 "git2",
 "log",
 "nonempty 0.8.1",
+
 "once_cell",
 "qcheck",
 "qcheck-macros",
 "radicle-crypto",
modified radicle-cli/src/commands/comment.rs
@@ -110,7 +110,7 @@ fn comment(
                let (comment_id, _) = issue.comments().next().expect("root comment always exists");
                *comment_id
            });
-
            let comment_id = issue.comment(message, comment_id, &signer)?;
+
            let comment_id = issue.comment(message, comment_id, vec![], &signer)?;

            term::print(comment_id);
            return Ok(());
modified radicle-cli/src/commands/issue.rs
@@ -295,7 +295,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            description: Some(description),
            labels,
        } => {
-
            let issue = issues.create(title, description, labels.as_slice(), &[], &signer)?;
+
            let issue = issues.create(title, description, labels.as_slice(), &[], [], &signer)?;
            if !options.quiet {
                show_issue(&issue, issue.id())?;
            }
@@ -569,6 +569,7 @@ fn open<R: WriteRepository + cob::Store, G: Signer>(
        description.trim(),
        meta.labels.as_slice(),
        meta.assignees.as_slice(),
+
        [],
        signer,
    )?;
    if !options.quiet {
@@ -598,7 +599,7 @@ fn edit<R: WriteRepository + cob::Store, G: radicle::crypto::Signer>(
                tx.edit(t)?;
            }
            if let Some(d) = description {
-
                tx.edit_comment(desc_id, d)?;
+
                tx.edit_comment(desc_id, d, vec![])?;
            }

            Ok(())
@@ -621,7 +622,7 @@ fn edit<R: WriteRepository + cob::Store, G: radicle::crypto::Signer>(

    issue.transaction("Edit", signer, |tx| {
        tx.edit(edited.title)?;
-
        tx.edit_comment(desc_id, description)?;
+
        tx.edit_comment(desc_id, description, vec![])?;
        tx.label(edited.labels)?;
        tx.assign(edited.assignees)?;

modified radicle-cli/tests/commands.rs
@@ -794,6 +794,7 @@ fn test_cob_replication() {
            "I don't know what it is",
            &[],
            &[],
+
            [],
            &bob.signer,
        )
        .unwrap();
@@ -843,6 +844,7 @@ fn test_cob_deletion() {
            "I don't know what it is",
            &[],
            &[],
+
            [],
            &alice.signer,
        )
        .unwrap();
modified radicle-cob/Cargo.toml
@@ -16,6 +16,7 @@ keywords = ["radicle", "cob", "cobs"]
fastrand = { version = "1.9.0" }
log = { version = "0.4.17" }
nonempty = { version = "0.8.1", features = ["serialize"] }
+
once_cell = { version = "1.13" }
radicle-git-ext = { version = "0.6.0", features = ["serde"] }
serde_json = { version = "1.0" }
thiserror = { version = "1.0" }
modified radicle-cob/src/backend/git/change.rs
@@ -2,11 +2,13 @@

use std::collections::BTreeMap;
use std::convert::TryFrom;
+
use std::path::PathBuf;

use git_ext::author::Author;
use git_ext::commit::{headers::Headers, Commit};
use git_ext::Oid;
use nonempty::NonEmpty;
+
use once_cell::sync::Lazy;
use radicle_git_ext::commit::trailers::OwnedTrailer;

use crate::change::store::Version;
@@ -16,10 +18,13 @@ use crate::{
    change::{self, store, Change},
    history::entry,
    signatures::{ExtendedSignature, Signatures},
-
    trailers,
+
    trailers, Embed,
};

-
const MANIFEST_BLOB_NAME: &str = "manifest";
+
/// Name of the COB manifest file.
+
pub const MANIFEST_BLOB_NAME: &str = "manifest";
+
/// Path under which COB embeds are kept.
+
pub static EMBEDS_PATH: Lazy<PathBuf> = Lazy::new(|| PathBuf::from("embeds"));

pub mod error {
    use std::str::Utf8Error;
@@ -102,10 +107,11 @@ impl change::Storage for git2::Repository {
            type_name,
            tips,
            message,
+
            embeds,
            contents,
        } = spec;
        let manifest = store::Manifest::new(type_name, Version::default());
-
        let revision = write_manifest(self, &manifest, &contents)?;
+
        let revision = write_manifest(self, &manifest, embeds, &contents)?;
        let tree = self.find_tree(revision)?;
        let signature = {
            let sig = signer.sign(revision.as_bytes());
@@ -281,6 +287,8 @@ where

    #[cfg(debug_assertions)]
    let (author, timestamp) = if let Ok(s) = std::env::var(crate::git::RAD_COMMIT_TIME) {
+
        // SAFETY: It's ok to panic here, since this is only enabled in debug mode.
+
        #[allow(clippy::unwrap_used)]
        let timestamp = s.trim().parse::<i64>().unwrap();
        let author = Author {
            time: git_ext::author::Time::new(timestamp, 0),
@@ -308,24 +316,47 @@ where
fn write_manifest(
    repo: &git2::Repository,
    manifest: &store::Manifest,
+
    embeds: Vec<Embed>,
    contents: &NonEmpty<Vec<u8>>,
) -> Result<git2::Oid, git2::Error> {
-
    let mut tb = repo.treebuilder(None)?;
-
    // SAFETY: we're serializing to an in memory buffer so the only source of
-
    // errors here is a programming error, which we can't recover from
-
    let serialized_manifest = serde_json::to_vec(manifest).unwrap();
-
    let manifest_oid = repo.blob(&serialized_manifest)?;
-
    tb.insert(
-
        MANIFEST_BLOB_NAME,
-
        manifest_oid,
-
        git2::FileMode::Blob.into(),
-
    )?;
+
    let mut root = repo.treebuilder(None)?;

+
    // Insert manifest file into tree.
+
    {
+
        // SAFETY: we're serializing to an in memory buffer so the only source of
+
        // errors here is a programming error, which we can't recover from.
+
        #[allow(clippy::unwrap_used)]
+
        let manifest = serde_json::to_vec(manifest).unwrap();
+
        let manifest_oid = repo.blob(&manifest)?;
+

+
        root.insert(
+
            MANIFEST_BLOB_NAME,
+
            manifest_oid,
+
            git2::FileMode::Blob.into(),
+
        )?;
+
    }
+

+
    // Insert each COB entry.
    for (ix, op) in contents.iter().enumerate() {
        let oid = repo.blob(op.as_ref())?;
-
        tb.insert(&ix.to_string(), oid, git2::FileMode::Blob.into())?;
+
        root.insert(&ix.to_string(), oid, git2::FileMode::Blob.into())?;
+
    }
+

+
    // Insert each embed in a tree at `/embeds`.
+
    if !embeds.is_empty() {
+
        let mut embeds_tree = repo.treebuilder(None)?;
+

+
        for embed in embeds {
+
            let oid = repo.blob(&embed.content)?;
+
            let path = PathBuf::from(embed.name);
+

+
            embeds_tree.insert(path, oid, git2::FileMode::Blob.into())?;
+
        }
+
        let oid = embeds_tree.write()?;
+

+
        root.insert(&*EMBEDS_PATH, oid, git2::FileMode::Tree.into())?;
    }
-
    let tree_oid = tb.write()?;
+
    let oid = root.write()?;

-
    Ok(tree_oid)
+
    Ok(oid)
}
modified radicle-cob/src/change/store.rs
@@ -48,6 +48,7 @@ pub struct Template<Id> {
    pub type_name: TypeName,
    pub tips: Vec<Id>,
    pub message: String,
+
    pub embeds: Vec<Embed>,
    pub contents: NonEmpty<Vec<u8>>,
}

@@ -179,3 +180,38 @@ impl Version {
        NonZeroUsize::new(version).map(Self)
    }
}
+

+
/// Embedded object.
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub struct Embed<T = Vec<u8>> {
+
    /// File name.
+
    pub name: String,
+
    /// File content or content hash.
+
    pub content: T,
+
}
+

+
impl Embed<Vec<u8>> {
+
    /// Get the object id of the embedded content.
+
    pub fn oid(&self) -> Oid {
+
        // SAFETY: This should not fail since we are using a valid object type.
+
        git2::Oid::hash_object(git2::ObjectType::Blob, &self.content)
+
            .expect("Embed::oid: invalid object")
+
            .into()
+
    }
+

+
    /// Return am embed where the content is replaced by a content hash.
+
    pub fn hashed<T: From<Oid>>(&self) -> Embed<T> {
+
        Embed {
+
            name: self.name.clone(),
+
            content: T::from(self.oid()),
+
        }
+
    }
+
}
+

+
impl Embed<Oid> {
+
    /// Get the object id of the embedded content.
+
    pub fn oid(&self) -> Oid {
+
        self.content
+
    }
+
}
modified radicle-cob/src/change_graph.rs
@@ -1,7 +1,7 @@
// Copyright © 2021 The Radicle Link Contributors

+
use std::collections::BTreeSet;
use std::ops::ControlFlow;
-
use std::{collections::BTreeSet, convert::TryInto};

use git_ext::Oid;
use radicle_dag::Dag;
@@ -135,8 +135,8 @@ impl ChangeGraph {
        self.graph.tips().map(|(_, change)| *change.id()).collect()
    }

-
    pub(crate) fn number_of_nodes(&self) -> u64 {
-
        self.graph.len().try_into().unwrap()
+
    pub(crate) fn number_of_nodes(&self) -> usize {
+
        self.graph.len()
    }
}

modified radicle-cob/src/lib.rs
@@ -1,5 +1,6 @@
// Copyright © 2021 The Radicle Link Contributors

+
#![warn(clippy::unwrap_used)]
//! # Collaborative Objects
//!
//! Collaborative objects are graphs of CRDTs. The current CRDTs that
@@ -80,7 +81,7 @@ mod change_graph;
mod trailers;

pub mod change;
-
pub use change::store::{Manifest, Version};
+
pub use change::store::{Embed, Manifest, Version};
pub use change::Change;

pub mod history;
@@ -98,9 +99,11 @@ pub use object::{
};

#[cfg(test)]
+
#[allow(clippy::unwrap_used)]
mod test;

#[cfg(test)]
+
#[allow(clippy::unwrap_used)]
mod tests;

/// The `Store` is an aggregation of the different types of storage
modified radicle-cob/src/object/collaboration/create.rs
@@ -2,6 +2,7 @@

use nonempty::NonEmpty;

+
use crate::Embed;
use crate::Store;

use super::*;
@@ -14,17 +15,20 @@ pub struct Create {
    pub type_name: TypeName,
    /// The message to add when creating this object.
    pub message: String,
+
    /// Embedded content.
+
    pub embeds: Vec<Embed>,
    /// COB version.
    pub version: Version,
}

impl Create {
-
    fn template(&self) -> change::Template<git_ext::Oid> {
+
    fn template(self) -> change::Template<git_ext::Oid> {
        change::Template {
-
            type_name: self.type_name.clone(),
+
            type_name: self.type_name,
            tips: Vec::new(),
-
            message: self.message.clone(),
-
            contents: self.contents.clone(),
+
            message: self.message,
+
            embeds: self.embeds,
+
            contents: self.contents,
        }
    }
}
@@ -59,14 +63,15 @@ where
    S: Store<I>,
    G: crypto::Signer,
{
-
    let Create { type_name, .. } = &args;
+
    let type_name = args.type_name.clone();
+
    let version = args.version;
    let init_change = storage
        .store(resource, parents, signer, args.template())
        .map_err(error::Create::from)?;
    let object_id = init_change.id().into();

    storage
-
        .update(identifier, type_name, &object_id, &init_change)
+
        .update(identifier, &type_name, &object_id, &init_change)
        .map_err(|err| error::Create::Refs { err: Box::new(err) })?;

    let history = History::new_from_root(
@@ -79,7 +84,7 @@ where
    );

    Ok(CollaborativeObject {
-
        manifest: Manifest::new(args.type_name, args.version),
+
        manifest: Manifest::new(type_name, version),
        history,
        id: object_id,
    })
modified radicle-cob/src/object/collaboration/info.rs
@@ -17,7 +17,7 @@ pub struct ChangeGraphInfo {
    /// The ID of the object
    pub object_id: ObjectId,
    /// The number of nodes in the change graph of the object
-
    pub number_of_nodes: u64,
+
    pub number_of_nodes: usize,
    /// The "tips" of the change graph, i.e the object IDs pointed to by
    /// references to the object
    pub tips: BTreeSet<Oid>,
modified radicle-cob/src/object/collaboration/update.rs
@@ -4,8 +4,8 @@ use git_ext::Oid;
use nonempty::NonEmpty;

use crate::{
-
    change, change_graph::ChangeGraph, history::EntryId, CollaborativeObject, ObjectId, Store,
-
    TypeName,
+
    change, change_graph::ChangeGraph, history::EntryId, CollaborativeObject, Embed, ObjectId,
+
    Store, TypeName,
};

use super::error;
@@ -31,6 +31,8 @@ pub struct Update {
    pub type_name: TypeName,
    /// The message to add when updating this object.
    pub message: String,
+
    /// Embedded files.
+
    pub embeds: Vec<Embed>,
}

/// Update an existing [`CollaborativeObject`].
@@ -67,6 +69,7 @@ where
    let Update {
        type_name: ref typename,
        object_id,
+
        embeds,
        changes,
        message,
    } = args;
@@ -85,6 +88,7 @@ where
        signer,
        change::Template {
            tips: object.tips().iter().cloned().collect(),
+
            embeds,
            contents: changes,
            type_name: typename.clone(),
            message,
modified radicle-cob/src/tests.rs
@@ -34,6 +34,7 @@ fn roundtrip() {
            contents: nonempty!(Vec::new()),
            type_name: typename.clone(),
            message: "creating xyz.rad.issue".to_string(),
+
            embeds: vec![],
            version: Version::default(),
        },
    )
@@ -67,6 +68,7 @@ fn list_cobs() {
            contents: nonempty!(b"issue 1".to_vec()),
            type_name: typename.clone(),
            message: "creating xyz.rad.issue".to_string(),
+
            embeds: vec![],
            version: Version::default(),
        },
    )
@@ -82,6 +84,7 @@ fn list_cobs() {
            contents: nonempty!(b"issue 2".to_vec()),
            type_name: typename.clone(),
            message: "commenting xyz.rad.issue".to_string(),
+
            embeds: vec![],
            version: Version::default(),
        },
    )
@@ -117,6 +120,7 @@ fn update_cob() {
            contents: nonempty!(Vec::new()),
            type_name: typename.clone(),
            message: "creating xyz.rad.issue".to_string(),
+
            embeds: vec![],
            version: Version::default(),
        },
    )
@@ -136,6 +140,7 @@ fn update_cob() {
            changes: nonempty!(b"issue 1".to_vec()),
            object_id: *cob.id(),
            type_name: typename.clone(),
+
            embeds: vec![],
            message: "commenting xyz.rad.issue".to_string(),
        },
    )
@@ -176,6 +181,7 @@ fn traverse_cobs() {
            contents: nonempty!(b"issue 1".to_vec()),
            type_name: typename.clone(),
            message: "creating xyz.rad.issue".to_string(),
+
            embeds: vec![],
            version: Version::default(),
        },
    )
@@ -199,6 +205,7 @@ fn traverse_cobs() {
            changes: nonempty!(b"issue 2".to_vec()),
            object_id: *cob.id(),
            type_name: typename,
+
            embeds: vec![],
            message: "commenting on xyz.rad.issue".to_string(),
        },
    )
modified radicle-cob/src/trailers.rs
@@ -58,6 +58,8 @@ impl From<git2::Oid> for ResourceCommitTrailer {
impl From<ResourceCommitTrailer> for Trailer<'_> {
    fn from(containing: ResourceCommitTrailer) -> Self {
        Trailer {
+
            // SAFETY: "Rad-Resource" is a valid `Token`.
+
            #[allow(clippy::unwrap_used)]
            token: Token::try_from("Rad-Resource").unwrap(),
            value: containing.0.to_string().into(),
        }
modified radicle-httpd/src/api/v1/projects.rs
@@ -507,6 +507,7 @@ async fn issue_create_handler(
            issue.description,
            &issue.labels,
            &issue.assignees,
+
            [],
            &signer,
        )
        .map_err(Error::from)?;
@@ -546,9 +547,9 @@ async fn issue_update_handler(
        issue::Action::Edit { title } => {
            issue.edit(title, &signer)?;
        }
-
        issue::Action::Comment { body, reply_to } => {
+
        issue::Action::Comment { body, reply_to, .. } => {
            if let Some(to) = reply_to {
-
                issue.comment(body, to, &signer)?;
+
                issue.comment(body, to, [], &signer)?;
            } else {
                return Err(Error::BadRequest("`replyTo` missing".to_owned()));
            }
modified radicle-httpd/src/test.rs
@@ -185,6 +185,7 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
            "Change 'hello world' to 'hello everyone'".to_string(),
            &[],
            &[],
+
            [],
            signer,
        )
        .unwrap();
modified radicle-node/src/test/environment.rs
@@ -315,7 +315,7 @@ impl<G: Signer + cyphernet::Ecdh> NodeHandle<G> {
        let repo = self.storage.repository(rid).unwrap();
        let mut issues = issue::Issues::open(&repo).unwrap();
        *issues
-
            .create(title, desc, &[], &[], &self.signer)
+
            .create(title, desc, &[], &[], [], &self.signer)
            .unwrap()
            .id()
    }
modified radicle/src/cob.rs
@@ -12,7 +12,7 @@ pub mod test;

pub use cob::{
    change, history::EntryId, object, object::collaboration::error, CollaborativeObject, Contents,
-
    Create, Entry, History, Manifest, ObjectId, Store, TypeName, Update, Updated, Version,
+
    Create, Embed, Entry, History, Manifest, ObjectId, Store, TypeName, Update, Updated, Version,
};
pub use cob::{create, get, list, remove, update};
pub use common::*;
modified radicle/src/cob/common.rs
@@ -4,6 +4,7 @@ use std::str::FromStr;
use localtime::LocalTime;
use serde::{Deserialize, Serialize};

+
use crate::git_ext::Oid;
use crate::prelude::*;

/// Timestamp used for COB operations.
@@ -226,6 +227,57 @@ impl<'a> Deserialize<'a> for Color {
    }
}

+
/// A URI.
+
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Serialize, Deserialize)]
+
#[serde(transparent)]
+
pub struct Uri(String);
+

+
impl Uri {
+
    /// Get a string reference to the URI.
+
    pub fn as_str(&self) -> &str {
+
        self.0.as_str()
+
    }
+
}
+

+
impl From<Oid> for Uri {
+
    fn from(oid: Oid) -> Self {
+
        Uri(format!("git:{oid}"))
+
    }
+
}
+

+
impl TryFrom<&Uri> for Oid {
+
    type Error = Uri;
+

+
    fn try_from(value: &Uri) -> Result<Self, Self::Error> {
+
        if let Some(oid) = value.as_str().strip_prefix("git:") {
+
            let oid = oid.parse().map_err(|_| value.clone())?;
+

+
            return Ok(oid);
+
        }
+
        Err(value.clone())
+
    }
+
}
+

+
impl std::fmt::Display for Uri {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        write!(f, "{}", self.0)
+
    }
+
}
+

+
impl std::str::FromStr for Uri {
+
    type Err = String;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        if !s.chars().all(|c| c.is_ascii()) {
+
            return Err(s.to_owned());
+
        }
+
        if !s.contains(':') {
+
            return Err(s.to_owned());
+
        }
+
        Ok(Self(s.to_owned()))
+
    }
+
}
+

#[cfg(test)]
mod test {
    use super::*;
modified radicle/src/cob/identity.rs
@@ -320,7 +320,7 @@ impl store::FromHistory for Proposal {
    type Error = ApplyError;

    fn type_name() -> &'static TypeName {
-
        &*TYPENAME
+
        &TYPENAME
    }

    fn validate(&self) -> Result<(), Self::Error> {
@@ -390,6 +390,7 @@ impl store::FromHistory for Proposal {
                            body,
                            reply_to,
                            None,
+
                            vec![],
                        )?;
                    }
                }
@@ -399,7 +400,14 @@ impl store::FromHistory for Proposal {
                    body,
                } => {
                    if let Some(revision) = lookup::revision(self, &revision)? {
-
                        thread::edit(&mut revision.discussion, op.id, comment, op.timestamp, body)?;
+
                        thread::edit(
+
                            &mut revision.discussion,
+
                            op.id,
+
                            comment,
+
                            op.timestamp,
+
                            body,
+
                            vec![],
+
                        )?;
                    }
                }
                Action::RevisionCommentRedact { revision, comment } => {
modified radicle/src/cob/issue.rs
@@ -7,12 +7,12 @@ use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::cob;
-
use crate::cob::common::{Author, Label, Reaction, Timestamp};
+
use crate::cob::common::{Author, Label, Reaction, Timestamp, Uri};
use crate::cob::store::Transaction;
use crate::cob::store::{FromHistory as _, HistoryAction};
use crate::cob::thread;
use crate::cob::thread::{CommentId, Thread};
-
use crate::cob::{store, EntryId, ObjectId, TypeName};
+
use crate::cob::{store, Embed, EntryId, ObjectId, TypeName};
use crate::crypto::Signer;
use crate::prelude::{Did, ReadRepository};
use crate::storage::WriteRepository;
@@ -109,7 +109,7 @@ impl store::FromHistory for Issue {
    type Error = Error;

    fn type_name() -> &'static TypeName {
-
        &*TYPENAME
+
        &TYPENAME
    }

    fn validate(&self) -> Result<(), Self::Error> {
@@ -154,7 +154,11 @@ impl store::FromHistory for Issue {
                Action::Label { labels } => {
                    self.labels = BTreeSet::from_iter(labels);
                }
-
                Action::Comment { body, reply_to } => {
+
                Action::Comment {
+
                    body,
+
                    reply_to,
+
                    embeds,
+
                } => {
                    thread::comment(
                        &mut self.thread,
                        op.id,
@@ -163,10 +167,11 @@ impl store::FromHistory for Issue {
                        body,
                        reply_to,
                        None,
+
                        embeds,
                    )?;
                }
-
                Action::CommentEdit { id, body } => {
-
                    thread::edit(&mut self.thread, op.id, id, op.timestamp, body)?;
+
                Action::CommentEdit { id, body, embeds } => {
+
                    thread::edit(&mut self.thread, op.id, id, op.timestamp, body, embeds)?;
                }
                Action::CommentRedact { id } => {
                    thread::redact(&mut self.thread, op.id, id)?;
@@ -252,10 +257,19 @@ impl store::Transaction<Issue> {
    }

    /// Edit an issue comment.
-
    pub fn edit_comment(&mut self, id: CommentId, body: impl ToString) -> Result<(), store::Error> {
+
    pub fn edit_comment(
+
        &mut self,
+
        id: CommentId,
+
        body: impl ToString,
+
        embeds: Vec<Embed>,
+
    ) -> Result<(), store::Error> {
+
        let hashed = embeds.iter().map(|e| e.hashed()).collect();
+

+
        self.embed(embeds)?;
        self.push(Action::CommentEdit {
            id,
            body: body.to_string(),
+
            embeds: hashed,
        })
    }

@@ -276,10 +290,15 @@ impl store::Transaction<Issue> {
        &mut self,
        body: S,
        reply_to: CommentId,
+
        embeds: Vec<Embed>,
    ) -> Result<(), store::Error> {
+
        let hashed = embeds.iter().map(|e| e.hashed()).collect();
+

+
        self.embed(embeds)?;
        self.push(Action::Comment {
            body: body.to_string(),
            reply_to: Some(reply_to),
+
            embeds: hashed,
        })
    }

@@ -307,10 +326,19 @@ impl store::Transaction<Issue> {
    ////////////////////////////////////////////////////////////////////////////////////////////////

    /// Create the issue thread.
-
    fn thread<S: ToString>(&mut self, body: S) -> Result<(), store::Error> {
+
    fn thread<S: ToString>(
+
        &mut self,
+
        body: S,
+
        embeds: impl IntoIterator<Item = Embed>,
+
    ) -> Result<(), store::Error> {
+
        let embeds = embeds.into_iter().collect::<Vec<_>>();
+
        let hashed = embeds.iter().map(|e| e.hashed()).collect();
+

+
        self.embed(embeds)?;
        self.push(Action::Comment {
            body: body.to_string(),
            reply_to: None,
+
            embeds: hashed,
        })
    }
}
@@ -367,6 +395,7 @@ where
    pub fn edit_description<G: Signer>(
        &mut self,
        description: impl ToString,
+
        embeds: impl IntoIterator<Item = Embed>,
        signer: &G,
    ) -> Result<EntryId, Error> {
        let Some((id, _)) = self.thread.comments().next() else {
@@ -374,7 +403,7 @@ where
        };
        let id = *id;
        self.transaction("Edit description", signer, |tx| {
-
            tx.edit_comment(id, description)
+
            tx.edit_comment(id, description, embeds.into_iter().collect())
        })
    }

@@ -388,13 +417,16 @@ where
        &mut self,
        body: S,
        reply_to: CommentId,
+
        embeds: impl IntoIterator<Item = Embed>,
        signer: &G,
    ) -> Result<EntryId, Error> {
        assert!(
            self.thread.comment(&reply_to).is_some(),
            "Comment {reply_to} not found"
        );
-
        self.transaction("Comment", signer, |tx| tx.comment(body, reply_to))
+
        self.transaction("Comment", signer, |tx| {
+
            tx.comment(body, reply_to, embeds.into_iter().collect())
+
        })
    }

    /// Label an issue.
@@ -502,10 +534,11 @@ where
        description: impl ToString,
        labels: &[Label],
        assignees: &[Did],
+
        embeds: impl IntoIterator<Item = Embed>,
        signer: &G,
    ) -> Result<IssueMut<'a, 'g, R>, Error> {
        let (id, issue) = Transaction::initial("Create issue", &mut self.raw, signer, |tx| {
-
            tx.thread(description)?;
+
            tx.thread(description, embeds)?;
            tx.assign(assignees.to_owned())?;
            tx.edit(title)?;
            tx.label(labels.to_owned())?;
@@ -573,11 +606,21 @@ pub enum Action {
        /// Should be the root [`CommentId`] if it's a top-level comment.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        reply_to: Option<CommentId>,
+
        /// Embeded content.
+
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
        embeds: Vec<Embed<Uri>>,
    },

    /// Edit a comment.
    #[serde(rename = "comment.edit")]
-
    CommentEdit { id: CommentId, body: String },
+
    CommentEdit {
+
        /// Comment being edited.
+
        id: CommentId,
+
        /// New value for the comment body.
+
        body: String,
+
        /// New value for the embeds list.
+
        embeds: Vec<Embed<Uri>>,
+
    },

    /// Redact a change. Not all changes can be redacted.
    #[serde(rename = "comment.redact")]
@@ -600,6 +643,7 @@ mod test {

    use super::*;
    use crate::cob::{ActorId, Reaction};
+
    use crate::git::Oid;
    use crate::test;
    use crate::test::arbitrary;

@@ -611,7 +655,14 @@ mod test {
        let mut eve_issues = Issues::open(&*t.eve.repo).unwrap();

        let mut issue_alice = issues_alice
-
            .create("Alice Issue", "Alice's comment", &[], &[], &t.alice.signer)
+
            .create(
+
                "Alice Issue",
+
                "Alice's comment",
+
                &[],
+
                &[],
+
                [],
+
                &t.alice.signer,
+
            )
            .unwrap();
        let id = *issue_alice.id();

@@ -622,10 +673,10 @@ mod test {
        let mut issue_bob = bob_issues.get_mut(&id).unwrap();

        issue_bob
-
            .comment("Bob's reply", id.into(), &t.bob.signer)
+
            .comment("Bob's reply", id.into(), vec![], &t.bob.signer)
            .unwrap();
        issue_alice
-
            .comment("Alice's reply", id.into(), &t.alice.signer)
+
            .comment("Alice's reply", id.into(), vec![], &t.alice.signer)
            .unwrap();

        assert_eq!(issue_bob.comments().count(), 2);
@@ -653,7 +704,7 @@ mod test {
        t.eve.repo.fetch(&t.alice);

        let eve_reply = issue_eve
-
            .comment("Eve's reply", id.into(), &t.eve.signer)
+
            .comment("Eve's reply", id.into(), vec![], &t.eve.signer)
            .unwrap();

        t.bob.repo.fetch(&t.eve);
@@ -698,6 +749,7 @@ mod test {
                "Blah blah blah.",
                &[],
                &[assignee],
+
                [],
                &node.signer,
            )
            .unwrap();
@@ -736,6 +788,7 @@ mod test {
                "Blah blah blah.",
                &[],
                &[assignee],
+
                [],
                &node.signer,
            )
            .unwrap();
@@ -760,7 +813,14 @@ mod test {
        let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
        let mut issues = Issues::open(&*repo).unwrap();
        let created = issues
-
            .create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
+
            .create(
+
                "My first issue",
+
                "Blah blah blah.",
+
                &[],
+
                &[],
+
                [],
+
                &node.signer,
+
            )
            .unwrap();

        let (id, created) = (created.id, created.issue);
@@ -779,7 +839,14 @@ mod test {
        let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
        let mut issues = Issues::open(&*repo).unwrap();
        let mut issue = issues
-
            .create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
+
            .create(
+
                "My first issue",
+
                "Blah blah blah.",
+
                &[],
+
                &[],
+
                [],
+
                &node.signer,
+
            )
            .unwrap();

        issue
@@ -820,6 +887,7 @@ mod test {
                "Blah blah blah.",
                &[],
                &[assignee, assignee_two],
+
                [],
                &node.signer,
            )
            .unwrap();
@@ -839,7 +907,14 @@ mod test {
        let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
        let mut issues = Issues::open(&*repo).unwrap();
        let mut issue = issues
-
            .create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
+
            .create(
+
                "My first issue",
+
                "Blah blah blah.",
+
                &[],
+
                &[],
+
                [],
+
                &node.signer,
+
            )
            .unwrap();

        issue.edit("Sorry typo", &node.signer).unwrap();
@@ -856,11 +931,18 @@ mod test {
        let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
        let mut issues = Issues::open(&*repo).unwrap();
        let mut issue = issues
-
            .create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
+
            .create(
+
                "My first issue",
+
                "Blah blah blah.",
+
                &[],
+
                &[],
+
                [],
+
                &node.signer,
+
            )
            .unwrap();

        issue
-
            .edit_description("Bob Loblaw law blog", &node.signer)
+
            .edit_description("Bob Loblaw law blog", vec![], &node.signer)
            .unwrap();

        let id = issue.id;
@@ -875,7 +957,14 @@ mod test {
        let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
        let mut issues = Issues::open(&*repo).unwrap();
        let mut issue = issues
-
            .create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
+
            .create(
+
                "My first issue",
+
                "Blah blah blah.",
+
                &[],
+
                &[],
+
                [],
+
                &node.signer,
+
            )
            .unwrap();

        let (comment, _) = issue.root();
@@ -897,13 +986,24 @@ mod test {
        let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
        let mut issues = Issues::open(&*repo).unwrap();
        let mut issue = issues
-
            .create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
+
            .create(
+
                "My first issue",
+
                "Blah blah blah.",
+
                &[],
+
                &[],
+
                [],
+
                &node.signer,
+
            )
            .unwrap();
        let (root, _) = issue.root();
        let root = *root;

-
        let c1 = issue.comment("Hi hi hi.", root, &node.signer).unwrap();
-
        let c2 = issue.comment("Ha ha ha.", root, &node.signer).unwrap();
+
        let c1 = issue
+
            .comment("Hi hi hi.", root, vec![], &node.signer)
+
            .unwrap();
+
        let c2 = issue
+
            .comment("Ha ha ha.", root, vec![], &node.signer)
+
            .unwrap();

        let id = issue.id;
        let mut issue = issues.get_mut(&id).unwrap();
@@ -913,10 +1013,14 @@ mod test {
        assert_eq!(reply1.body(), "Hi hi hi.");
        assert_eq!(reply2.body(), "Ha ha ha.");

-
        issue.comment("Re: Hi.", c1, &node.signer).unwrap();
-
        issue.comment("Re: Ha.", c2, &node.signer).unwrap();
-
        issue.comment("Re: Ha. Ha.", c2, &node.signer).unwrap();
-
        issue.comment("Re: Ha. Ha. Ha.", c2, &node.signer).unwrap();
+
        issue.comment("Re: Hi.", c1, vec![], &node.signer).unwrap();
+
        issue.comment("Re: Ha.", c2, vec![], &node.signer).unwrap();
+
        issue
+
            .comment("Re: Ha. Ha.", c2, vec![], &node.signer)
+
            .unwrap();
+
        issue
+
            .comment("Re: Ha. Ha. Ha.", c2, vec![], &node.signer)
+
            .unwrap();

        let issue = issues.get(&id).unwrap().unwrap();

@@ -942,6 +1046,7 @@ mod test {
                "Blah blah blah.",
                &[ux_label.clone()],
                &[],
+
                [],
                &node.signer,
            )
            .unwrap();
@@ -971,15 +1076,26 @@ mod test {
        let author = *node.signer.public_key();
        let mut issues = Issues::open(&*repo).unwrap();
        let mut issue = issues
-
            .create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
+
            .create(
+
                "My first issue",
+
                "Blah blah blah.",
+
                &[],
+
                &[],
+
                [],
+
                &node.signer,
+
            )
            .unwrap();

        // The root thread op id is always the same.
        let (c0, _) = issue.root();
        let c0 = *c0;

-
        issue.comment("Ho ho ho.", c0, &node.signer).unwrap();
-
        issue.comment("Ha ha ha.", c0, &node.signer).unwrap();
+
        issue
+
            .comment("Ho ho ho.", c0, vec![], &node.signer)
+
            .unwrap();
+
        issue
+
            .comment("Ha ha ha.", c0, vec![], &node.signer)
+
            .unwrap();

        let id = issue.id;
        let issue = issues.get(&id).unwrap().unwrap();
@@ -1017,13 +1133,13 @@ mod test {
        let mut issues = Issues::open(&*repo).unwrap();

        issues
-
            .create("First", "Blah", &[], &[], &node.signer)
+
            .create("First", "Blah", &[], &[], [], &node.signer)
            .unwrap();
        issues
-
            .create("Second", "Blah", &[], &[], &node.signer)
+
            .create("Second", "Blah", &[], &[], [], &node.signer)
            .unwrap();
        issues
-
            .create("Third", "Blah", &[], &[], &node.signer)
+
            .create("Third", "Blah", &[], &[], [], &node.signer)
            .unwrap();

        let issues = issues
@@ -1050,6 +1166,7 @@ mod test {
                "Blah blah blah.\nYah yah yah",
                &[],
                &[],
+
                [],
                &node.signer,
            )
            .unwrap();
@@ -1064,4 +1181,106 @@ mod test {
        assert_eq!(issue.comments().count(), 1);
        assert_eq!(issue.state(), &State::Open);
    }
+

+
    #[test]
+
    fn test_embeds() {
+
        let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
+
        let mut issues = Issues::open(&*repo).unwrap();
+
        let embed1 = Embed {
+
            name: String::from("example.html"),
+
            content: b"<html>Hello World!</html>".to_vec(),
+
        };
+
        let embed2 = Embed {
+
            name: String::from("style.css"),
+
            content: b"body { color: red }".to_vec(),
+
        };
+
        let embed3 = Embed {
+
            name: String::from("bin"),
+
            content: vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
+
        };
+
        let mut issue = issues
+
            .create(
+
                "My first issue",
+
                "Blah blah blah.",
+
                &[],
+
                &[],
+
                [embed1.clone(), embed2.clone()],
+
                &node.signer,
+
            )
+
            .unwrap();
+

+
        issue
+
            .comment(
+
                "Here's a binary file",
+
                issue.id.into(),
+
                [embed3.clone()],
+
                &node.signer,
+
            )
+
            .unwrap();
+

+
        issue.reload().unwrap();
+

+
        let (_, c0) = issue.thread().comments().next().unwrap();
+
        let (_, c1) = issue.thread().comments().next_back().unwrap();
+

+
        let e1 = &c0.embeds()[0];
+
        let e2 = &c0.embeds()[1];
+
        let e3 = &c1.embeds()[0];
+

+
        let b1 = repo.blob(Oid::try_from(&e1.content).unwrap()).unwrap();
+
        let b2 = repo.blob(Oid::try_from(&e2.content).unwrap()).unwrap();
+
        let b3 = repo.blob(Oid::try_from(&e3.content).unwrap()).unwrap();
+

+
        assert_eq!(b1.content(), &embed1.content);
+
        assert_eq!(b2.content(), &embed2.content);
+
        assert_eq!(b3.content(), &embed3.content);
+

+
        assert_eq!(b1.is_binary(), false);
+
        assert_eq!(b2.is_binary(), false);
+
        assert_eq!(b3.is_binary(), true);
+
    }
+

+
    #[test]
+
    fn test_embeds_edit() {
+
        let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
+
        let mut issues = Issues::open(&*repo).unwrap();
+
        let embed1 = Embed {
+
            name: String::from("example.html"),
+
            content: b"<html>Hello World!</html>".to_vec(),
+
        };
+
        let embed1_edited = Embed {
+
            name: String::from("example.html"),
+
            content: b"<html>Hello Radicle!</html>".to_vec(),
+
        };
+
        let embed2 = Embed {
+
            name: String::from("style.css"),
+
            content: b"body { color: red }".to_vec(),
+
        };
+
        let mut issue = issues
+
            .create(
+
                "My first issue",
+
                "Blah blah blah.",
+
                &[],
+
                &[],
+
                [embed1.clone(), embed2.clone()],
+
                &node.signer,
+
            )
+
            .unwrap();
+

+
        issue.reload().unwrap();
+
        issue
+
            .edit_description("My first issue", [embed1_edited.clone()], &node.signer)
+
            .unwrap();
+
        issue.reload().unwrap();
+

+
        let (_, c0) = issue.thread().comments().next().unwrap();
+

+
        assert_eq!(c0.embeds().len(), 1);
+

+
        let e1 = &c0.embeds()[0];
+
        let b1 = repo.blob(Oid::try_from(&e1.content).unwrap()).unwrap();
+

+
        assert_eq!(e1.content, Uri::from(embed1_edited.oid()));
+
        assert_eq!(b1.content(), &embed1_edited.content);
+
    }
}
modified radicle/src/cob/legacy/issue.rs
@@ -28,7 +28,7 @@ impl store::FromHistory for Issue {
    type Error = Error;

    fn type_name() -> &'static TypeName {
-
        &*issue::TYPENAME
+
        &issue::TYPENAME
    }

    fn validate(&self) -> Result<(), Self::Error> {
modified radicle/src/cob/legacy/patch.rs
@@ -103,7 +103,7 @@ impl store::FromHistory for Patch {
    type Error = Error;

    fn type_name() -> &'static TypeName {
-
        &*TYPENAME
+
        &TYPENAME
    }

    fn validate(&self) -> Result<(), Self::Error> {
@@ -242,6 +242,7 @@ impl store::FromHistory for Patch {
                                        comment,
                                        timestamp,
                                        body,
+
                                        vec![],
                                    )?;
                                }
                            }
@@ -277,6 +278,7 @@ impl store::FromHistory for Patch {
                                        body,
                                        None,
                                        Some(location),
+
                                        vec![],
                                    )?;
                                }
                            }
modified radicle/src/cob/patch.rs
@@ -449,7 +449,7 @@ impl store::FromHistory for Patch {
    type Error = Error;

    fn type_name() -> &'static TypeName {
-
        &*TYPENAME
+
        &TYPENAME
    }

    fn validate(&self) -> Result<(), Self::Error> {
@@ -626,7 +626,14 @@ impl store::FromHistory for Patch {
                    body,
                } => {
                    if let Some(review) = lookup::review(self, &review)? {
-
                        thread::edit(&mut review.comments, op.id, comment, timestamp, body)?;
+
                        thread::edit(
+
                            &mut review.comments,
+
                            op.id,
+
                            comment,
+
                            timestamp,
+
                            body,
+
                            vec![],
+
                        )?;
                    }
                }
                Action::ReviewCommentResolve { .. } => {
@@ -650,6 +657,7 @@ impl store::FromHistory for Patch {
                            body,
                            reply_to,
                            location,
+
                            vec![],
                        )?;
                    }
                }
@@ -739,6 +747,7 @@ impl store::FromHistory for Patch {
                            body,
                            reply_to,
                            None,
+
                            vec![],
                        )?;
                    }
                }
@@ -748,7 +757,14 @@ impl store::FromHistory for Patch {
                    body,
                } => {
                    if let Some(revision) = lookup::revision(self, &revision)? {
-
                        thread::edit(&mut revision.discussion, op.id, comment, op.timestamp, body)?;
+
                        thread::edit(
+
                            &mut revision.discussion,
+
                            op.id,
+
                            comment,
+
                            op.timestamp,
+
                            body,
+
                            vec![],
+
                        )?;
                    }
                }
                Action::RevisionCommentRedact { revision, comment } => {
modified radicle/src/cob/store.rs
@@ -10,7 +10,9 @@ use serde::{Deserialize, Serialize};

use crate::cob::common::Timestamp;
use crate::cob::op::Op;
-
use crate::cob::{ActorId, Create, EntryId, History, ObjectId, TypeName, Update, Updated, Version};
+
use crate::cob::{
+
    ActorId, Create, Embed, EntryId, History, ObjectId, TypeName, Update, Updated, Version,
+
};
use crate::git;
use crate::prelude::*;
use crate::storage::git as storage;
@@ -165,6 +167,7 @@ where
        object_id: ObjectId,
        message: &str,
        actions: impl Into<NonEmpty<T::Action>>,
+
        embeds: Vec<Embed>,
        signer: &G,
    ) -> Result<Updated, Error> {
        let actions = actions.into();
@@ -180,6 +183,7 @@ where
                object_id,
                type_name: T::type_name().clone(),
                message: message.to_owned(),
+
                embeds,
                changes,
            },
        )?;
@@ -194,6 +198,7 @@ where
        &self,
        message: &str,
        actions: impl Into<NonEmpty<T::Action>>,
+
        embeds: Vec<Embed>,
        signer: &G,
    ) -> Result<(ObjectId, T), Error> {
        let actions = actions.into();
@@ -209,6 +214,7 @@ where
                type_name: T::type_name().clone(),
                version: Version::default(),
                message: message.to_owned(),
+
                embeds,
                contents,
            },
        )?;
@@ -288,6 +294,7 @@ where
pub struct Transaction<T: FromHistory> {
    actor: ActorId,
    actions: Vec<T::Action>,
+
    embeds: Vec<Embed>,
}

impl<T: FromHistory> Transaction<T> {
@@ -296,6 +303,7 @@ impl<T: FromHistory> Transaction<T> {
        Self {
            actor,
            actions: Vec::new(),
+
            embeds: Vec::new(),
        }
    }

@@ -313,15 +321,13 @@ impl<T: FromHistory> Transaction<T> {
        T::Action: Serialize + Clone,
    {
        let actor = *signer.public_key();
-
        let mut tx = Transaction {
-
            actor,
-
            actions: Vec::new(),
-
        };
+
        let mut tx = Transaction::new(actor);
+

        operations(&mut tx)?;

        let actions = NonEmpty::from_vec(tx.actions)
            .expect("Transaction::initial: transaction must contain at least one operation");
-
        let (id, cob) = store.create(message, actions, signer)?;
+
        let (id, cob) = store.create(message, actions, tx.embeds, signer)?;

        Ok((id, cob))
    }
@@ -333,6 +339,13 @@ impl<T: FromHistory> Transaction<T> {
        Ok(())
    }

+
    /// Embed media into the transaction.
+
    pub fn embed(&mut self, embeds: impl IntoIterator<Item = Embed>) -> Result<(), Error> {
+
        self.embeds.extend(embeds);
+

+
        Ok(())
+
    }
+

    /// Commit transaction.
    ///
    /// Returns an operation that can be applied onto an in-memory state.
@@ -353,7 +366,7 @@ impl<T: FromHistory> Transaction<T> {
            head,
            object,
            parents,
-
        } = store.update(id, msg, actions.clone(), signer)?;
+
        } = store.update(id, msg, actions.clone(), self.embeds, signer)?;
        let id = EntryId::from(head);
        let author = self.actor;
        let timestamp = Timestamp::from_secs(object.history().timestamp());
modified radicle/src/cob/thread.rs
@@ -7,8 +7,8 @@ use serde::{ser::SerializeStruct, Deserialize, Serialize};
use thiserror::Error;

use crate::cob;
-
use crate::cob::common::{Reaction, Timestamp};
-
use crate::cob::{ActorId, EntryId, Op};
+
use crate::cob::common::{Reaction, Timestamp, Uri};
+
use crate::cob::{ActorId, Embed, EntryId, Op};
use crate::prelude::ReadRepository;

/// Type name of a thread, as well as the domain for all thread operations.
@@ -49,6 +49,8 @@ pub struct Edit {
    pub timestamp: Timestamp,
    /// Edit contents. Replaces previous edits.
    pub body: String,
+
    /// Edit embed list.
+
    pub embeds: Vec<Embed<Uri>>,
}

/// A comment on a discussion thread.
@@ -72,7 +74,7 @@ impl<T: Serialize> Serialize for Comment<T> {
    where
        S: serde::ser::Serializer,
    {
-
        let mut state = serializer.serialize_struct("Comment", 5)?;
+
        let mut state = serializer.serialize_struct("Comment", 6)?;
        state.serialize_field("author", &self.author())?;
        if let Some(loc) = &self.location {
            state.serialize_field("location", loc)?;
@@ -82,6 +84,7 @@ impl<T: Serialize> Serialize for Comment<T> {
        }
        state.serialize_field("reactions", &self.reactions)?;
        state.serialize_field("body", self.body())?;
+
        state.serialize_field("embeds", self.embeds())?;
        state.end()
    }
}
@@ -93,9 +96,14 @@ impl<L> Comment<L> {
        body: String,
        reply_to: Option<CommentId>,
        location: Option<L>,
+
        embeds: Vec<Embed<Uri>>,
        timestamp: Timestamp,
    ) -> Self {
-
        let edit = Edit { body, timestamp };
+
        let edit = Edit {
+
            body,
+
            embeds,
+
            timestamp,
+
        };

        Self {
            author,
@@ -139,8 +147,12 @@ impl<L> Comment<L> {
    }

    /// Add an edit.
-
    pub fn edit(&mut self, body: String, timestamp: Timestamp) {
-
        self.edits.push(Edit { body, timestamp });
+
    pub fn edit(&mut self, body: String, embeds: Vec<Embed<Uri>>, timestamp: Timestamp) {
+
        self.edits.push(Edit {
+
            body,
+
            embeds,
+
            timestamp,
+
        });
    }

    /// Comment reactions.
@@ -152,6 +164,14 @@ impl<L> Comment<L> {
    pub fn location(&self) -> Option<&L> {
        self.location.as_ref()
    }
+

+
    /// Return the embedded media.
+
    pub fn embeds(&self) -> &[Embed<Uri>] {
+
        // SAFETY: There is always at least one edit. This is guaranteed by the [`Comment`]
+
        // constructor.
+
        #[allow(clippy::unwrap_used)]
+
        &self.edits.last().unwrap().embeds
+
    }
}

impl<T: PartialOrd> PartialOrd for Comment<T> {
@@ -283,7 +303,7 @@ impl cob::store::FromHistory for Thread {
    type Error = Error;

    fn type_name() -> &'static radicle_cob::TypeName {
-
        &*TYPENAME
+
        &TYPENAME
    }

    fn validate(&self) -> Result<(), Self::Error> {
@@ -301,10 +321,10 @@ impl cob::store::FromHistory for Thread {
        for action in op.actions {
            match action {
                Action::Comment { body, reply_to } => {
-
                    comment(self, id, author, timestamp, body, reply_to, None)?;
+
                    comment(self, id, author, timestamp, body, reply_to, None, vec![])?;
                }
                Action::Edit { id, body } => {
-
                    edit(self, op.id, id, timestamp, body)?;
+
                    edit(self, op.id, id, timestamp, body, vec![])?;
                }
                Action::Redact { id } => {
                    redact(self, op.id, id)?;
@@ -330,6 +350,7 @@ pub fn comment<L>(
    body: String,
    reply_to: Option<CommentId>,
    location: Option<L>,
+
    embeds: Vec<Embed<Uri>>,
) -> Result<(), Error> {
    if body.is_empty() {
        return Err(Error::Comment(id));
@@ -341,7 +362,9 @@ pub fn comment<L>(
    // underlying store guarantees exactly-once delivery of ops.
    thread.comments.insert(
        id,
-
        Some(Comment::new(author, body, reply_to, location, timestamp)),
+
        Some(Comment::new(
+
            author, body, reply_to, location, embeds, timestamp,
+
        )),
    );

    Ok(())
@@ -353,6 +376,7 @@ pub fn edit<L>(
    comment: EntryId,
    timestamp: Timestamp,
    body: String,
+
    embeds: Vec<Embed<Uri>>,
) -> Result<(), Error> {
    if body.is_empty() {
        return Err(Error::Edit(id));
@@ -367,7 +391,7 @@ pub fn edit<L>(
    // that as an error.
    if let Some(comment) = thread.comments.get_mut(&comment) {
        if let Some(comment) = comment {
-
            comment.edit(body, timestamp);
+
            comment.edit(body, embeds, timestamp);
        }
    } else {
        return Err(Error::Missing(comment));
modified radicle/src/lib.rs
@@ -1,5 +1,5 @@
#![allow(clippy::match_like_matches_macro)]
-
#![allow(clippy::explicit_auto_deref)] // TODO: This can be removed when the clippy bugs are fixed
+
#![allow(clippy::too_many_arguments)]
#![allow(clippy::iter_nth_zero)]

pub extern crate radicle_crypto as crypto;
modified radicle/src/storage.rs
@@ -333,6 +333,9 @@ pub trait ReadRepository: Sized {
    fn blob_at<'a>(&'a self, commit: Oid, path: &'a Path)
        -> Result<git2::Blob<'a>, git_ext::Error>;

+
    /// Get a blob in this repository, given its id.
+
    fn blob(&self, oid: Oid) -> Result<git2::Blob, git_ext::Error>;
+

    /// Validate all remotes with [`ReadRepository::validate_remote`].
    fn validate(&self) -> Result<(), VerifyError> {
        for (_, remote) in self.remotes()? {
modified radicle/src/storage/git.rs
@@ -357,14 +357,18 @@ impl ReadRepository for Repository {
        self.backend.path()
    }

-
    fn blob_at<'a>(&'a self, oid: Oid, path: &'a Path) -> Result<git2::Blob<'a>, git::Error> {
+
    fn blob_at<'a>(&'a self, commit: Oid, path: &'a Path) -> Result<git2::Blob<'a>, git::Error> {
        git::ext::Blob::At {
-
            object: oid.into(),
+
            object: commit.into(),
            path,
        }
        .get(&self.backend)
    }

+
    fn blob(&self, oid: Oid) -> Result<git2::Blob, git::Error> {
+
        self.backend.find_blob(oid.into()).map_err(git::Error::from)
+
    }
+

    fn validate_remote(&self, remote: &Remote<Verified>) -> Result<Vec<RefString>, VerifyError> {
        // Contains a copy of the signed refs of this remote.
        let mut signed = BTreeMap::from((*remote.refs).clone());
modified radicle/src/storage/git/cob.rs
@@ -283,6 +283,10 @@ impl<'a> ReadRepository for DraftStore<'a> {
        self.repo.blob_at(oid, path)
    }

+
    fn blob(&self, oid: git_ext::Oid) -> Result<raw::Blob, ext::Error> {
+
        self.repo.blob(oid)
+
    }
+

    fn reference(
        &self,
        remote: &RemoteId,
modified radicle/src/test/storage.rs
@@ -178,6 +178,10 @@ impl ReadRepository for MockRepository {
        Ok(true)
    }

+
    fn blob(&self, _oid: Oid) -> Result<git2::Blob, git_ext::Error> {
+
        todo!()
+
    }
+

    fn blob_at<'a>(
        &'a self,
        _oid: git_ext::Oid,