mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-21 06:28:11 +08:00
Implement content-addressed resource storage for raster images (#4148)
* Implement content-addressed resource storage * Implement OPFS resource storage * Remove ResourceStorage::read * Review
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
use crate::dispatcher::Dispatcher;
|
||||
use crate::messages::prelude::*;
|
||||
use graph_craft::application_io::{PlatformApplicationIo, ResourceStorage};
|
||||
pub use graphene_std::uuid::*;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -8,11 +9,13 @@ pub struct Editor {
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
pub fn new(environment: Environment, uuid_random_seed: u64) -> Self {
|
||||
pub fn new(environment: Environment, uuid_random_seed: u64, resource_storage: Box<dyn ResourceStorage>) -> Self {
|
||||
ENVIRONMENT.set(environment).expect("Editor shoud only be initialized once");
|
||||
graphene_std::uuid::set_uuid_seed(uuid_random_seed);
|
||||
|
||||
Self { dispatcher: Dispatcher::new() }
|
||||
Self {
|
||||
dispatcher: Dispatcher::new(resource_storage),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -20,11 +23,15 @@ impl Editor {
|
||||
let _ = ENVIRONMENT.set(*Editor::environment());
|
||||
graphene_std::uuid::set_uuid_seed(0);
|
||||
|
||||
let (runtime, executor) = crate::node_graph_executor::NodeGraphExecutor::new_with_local_runtime();
|
||||
let (mut runtime, executor) = crate::node_graph_executor::NodeGraphExecutor::new_with_local_runtime();
|
||||
let editor = Self {
|
||||
dispatcher: Dispatcher::with_executor(executor),
|
||||
};
|
||||
|
||||
let mut application_io = PlatformApplicationIo::default();
|
||||
application_io.inject_resource_proxy(editor.dispatcher.message_handlers.resource_message_handler.resources());
|
||||
runtime.replace_application_io(application_io);
|
||||
|
||||
(editor, runtime)
|
||||
}
|
||||
|
||||
@@ -37,6 +44,11 @@ impl Editor {
|
||||
pub fn poll_node_graph_evaluation(&mut self, responses: &mut VecDeque<Message>) -> Result<(), String> {
|
||||
self.dispatcher.poll_node_graph_evaluation(responses)
|
||||
}
|
||||
|
||||
pub fn replace_application_io(&mut self, mut application_io: PlatformApplicationIo) {
|
||||
application_io.inject_resource_proxy(self.dispatcher.message_handlers.resource_message_handler.resources());
|
||||
crate::node_graph_executor::replace_application_io(application_io)
|
||||
}
|
||||
}
|
||||
|
||||
static ENVIRONMENT: OnceLock<Environment> = OnceLock::new();
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::messages::portfolio::utility_types::PanelType;
|
||||
use crate::messages::preferences::preferences_message_handler::PreferencesMessageContext;
|
||||
use crate::messages::prelude::*;
|
||||
use crate::messages::tool::common_functionality::utility_functions::make_path_editable_is_allowed;
|
||||
use graph_craft::application_io::ResourceStorage;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Dispatcher {
|
||||
@@ -31,15 +32,23 @@ pub struct DispatcherMessageHandlers {
|
||||
menu_bar_message_handler: MenuBarMessageHandler,
|
||||
pub(crate) portfolio_message_handler: PortfolioMessageHandler,
|
||||
preferences_message_handler: PreferencesMessageHandler,
|
||||
pub(crate) resource_message_handler: ResourceMessageHandler,
|
||||
tool_message_handler: ToolMessageHandler,
|
||||
viewport_message_handler: ViewportMessageHandler,
|
||||
}
|
||||
|
||||
impl DispatcherMessageHandlers {
|
||||
pub fn with_resource_storage(resource_storage: Box<dyn ResourceStorage>) -> Self {
|
||||
Self {
|
||||
resource_message_handler: ResourceMessageHandler::new(resource_storage),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_executor(executor: crate::node_graph_executor::NodeGraphExecutor) -> Self {
|
||||
Self {
|
||||
portfolio_message_handler: PortfolioMessageHandler::with_executor(executor),
|
||||
..Default::default()
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,15 +87,16 @@ const DEBUG_MESSAGE_BLOCK_LIST: &[MessageDiscriminant] = &[
|
||||
const DEBUG_MESSAGE_ENDING_BLOCK_LIST: &[&str] = &["PointerMove", "PointerOutsideViewport", "Overlays", "Draw", "CurrentTime", "Time"];
|
||||
|
||||
impl Dispatcher {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
pub fn new(resource_storage: Box<dyn ResourceStorage>) -> Self {
|
||||
let mut s = Self::default();
|
||||
s.message_handlers.resource_message_handler = ResourceMessageHandler::new(resource_storage);
|
||||
s
|
||||
}
|
||||
|
||||
pub fn with_executor(executor: crate::node_graph_executor::NodeGraphExecutor) -> Self {
|
||||
Self {
|
||||
message_handlers: DispatcherMessageHandlers::with_executor(executor),
|
||||
..Default::default()
|
||||
}
|
||||
let mut s = Self::default();
|
||||
s.message_handlers.portfolio_message_handler = PortfolioMessageHandler::with_executor(executor);
|
||||
s
|
||||
}
|
||||
|
||||
// If the deepest queues (higher index in queues list) are now empty (after being popped from) then remove them
|
||||
@@ -218,6 +228,9 @@ impl Dispatcher {
|
||||
|
||||
self.message_handlers.layout_message_handler.process_message(message, &mut queue, context);
|
||||
}
|
||||
Message::Resource(message) => {
|
||||
self.message_handlers.resource_message_handler.process_message(message, &mut queue, ResourceMessageContext {});
|
||||
}
|
||||
Message::Portfolio(message) => {
|
||||
self.message_handlers.portfolio_message_handler.process_message(
|
||||
message,
|
||||
@@ -230,6 +243,7 @@ impl Dispatcher {
|
||||
timing_information: self.message_handlers.animation_message_handler.timing_information(),
|
||||
animation: &self.message_handlers.animation_message_handler,
|
||||
viewport: &self.message_handlers.viewport_message_handler,
|
||||
resources: &self.message_handlers.resource_message_handler,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::messages::layout::utility_types::widget_prelude::*;
|
||||
use crate::messages::portfolio::document::utility_types::wires::GraphWireStyle;
|
||||
use crate::messages::preferences::SelectionMode;
|
||||
use crate::messages::prelude::*;
|
||||
use graphene_std::render_node::{EditorPreferences, wgpu_available};
|
||||
use graph_craft::application_io::EditorPreferences;
|
||||
|
||||
#[derive(ExtractField)]
|
||||
pub struct PreferencesDialogMessageContext<'a> {
|
||||
@@ -307,7 +307,7 @@ impl PreferencesDialogMessageHandler {
|
||||
// COMPATIBILITY
|
||||
// =============
|
||||
{
|
||||
let wgpu_available = wgpu_available().unwrap_or(false);
|
||||
let wgpu_available = graph_craft::application_io::wgpu_available().unwrap_or(false);
|
||||
let is_desktop = cfg!(not(target_family = "wasm"));
|
||||
if wgpu_available || is_desktop {
|
||||
let header = vec![TextLabel::new("Compatibility").italic(true).widget_instance()];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::IconName;
|
||||
use super::utility_types::{MouseCursorIcon, PersistedState};
|
||||
use crate::messages::app_window::app_window_message_handler::AppWindowPlatform;
|
||||
use crate::messages::frontend::utility_types::{DocumentInfo, EyedropperPreviewImage};
|
||||
use crate::messages::frontend::utility_types::{DocumentInfo, EyedropperPreviewImage, FrontendMessageFuture};
|
||||
use crate::messages::input_mapper::utility_types::misc::ActionShortcut;
|
||||
use crate::messages::layout::utility_types::widget_prelude::*;
|
||||
use crate::messages::portfolio::document::node_graph::utility_types::{
|
||||
@@ -27,6 +27,13 @@ use crate::messages::portfolio::document::overlays::utility_types::OverlayContex
|
||||
#[derive(derivative::Derivative, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[derivative(Debug, PartialEq)]
|
||||
pub enum FrontendMessage {
|
||||
Await {
|
||||
#[serde(skip, default)]
|
||||
#[derivative(Debug = "ignore", PartialEq = "ignore")]
|
||||
#[cfg_attr(feature = "wasm", tsify(type = "unknown"))]
|
||||
future: FrontendMessageFuture,
|
||||
},
|
||||
|
||||
// Display prefix: make the frontend show something, like a dialog
|
||||
DisplayDialog {
|
||||
title: String,
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod utility_types;
|
||||
|
||||
#[doc(inline)]
|
||||
pub use frontend_message::{FrontendMessage, FrontendMessageDiscriminant};
|
||||
pub use utility_types::FrontendMessageFuture;
|
||||
|
||||
// TODO: Make this an enum with the actual icon names, somehow derived from or tied to the frontend icon set.
|
||||
// TODO: Then remove `#[widget_builder(string)]` from all icon fields.
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
use std::future::{Future, IntoFuture};
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use graph_craft::application_io::ResourceHash;
|
||||
|
||||
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
|
||||
use crate::messages::portfolio::utility_types::WorkspacePanelLayout;
|
||||
@@ -10,6 +15,9 @@ pub struct DocumentInfo {
|
||||
pub id: DocumentId,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
#[cfg_attr(feature = "wasm", tsify(type = "unknown"))]
|
||||
pub resources: Option<Box<[ResourceHash]>>,
|
||||
#[serde(default)]
|
||||
pub path: Option<PathBuf>,
|
||||
pub is_saved: bool,
|
||||
}
|
||||
@@ -77,3 +85,31 @@ pub struct EyedropperPreviewImage {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct FrontendMessageFuture {
|
||||
inner: Arc<Mutex<Option<InnerFrontendMessageFuture>>>,
|
||||
}
|
||||
|
||||
impl FrontendMessageFuture {
|
||||
pub fn new(future: impl Future<Output = FrontendMessage> + Send + 'static) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(Some(Box::pin(future)))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type InnerFrontendMessageFuture = Pin<Box<dyn Future<Output = FrontendMessage> + Send + 'static>>;
|
||||
|
||||
impl IntoFuture for FrontendMessageFuture {
|
||||
type Output = FrontendMessage;
|
||||
type IntoFuture = InnerFrontendMessageFuture;
|
||||
|
||||
fn into_future(self) -> Self::IntoFuture {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.take()
|
||||
.expect("FrontendMessageFuture can only be awaited once")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ pub enum Message {
|
||||
#[child]
|
||||
Preferences(PreferencesMessage),
|
||||
#[child]
|
||||
Resource(ResourceMessage),
|
||||
#[child]
|
||||
Tool(ToolMessage),
|
||||
#[child]
|
||||
Viewport(ViewportMessage),
|
||||
|
||||
@@ -17,5 +17,6 @@ pub mod message;
|
||||
pub mod portfolio;
|
||||
pub mod preferences;
|
||||
pub mod prelude;
|
||||
pub mod resource;
|
||||
pub mod tool;
|
||||
pub mod viewport;
|
||||
|
||||
@@ -19,6 +19,7 @@ use crate::messages::portfolio::document::overlays::grid_overlays::{grid_overlay
|
||||
use crate::messages::portfolio::document::overlays::utility_types::{OverlaysType, OverlaysVisibilitySettings, Pivot};
|
||||
use crate::messages::portfolio::document::properties_panel::properties_panel_message_handler::PropertiesPanelMessageContext;
|
||||
use crate::messages::portfolio::document::utility_types::document_metadata::{DocumentMetadata, LayerNodeIdentifier};
|
||||
use crate::messages::portfolio::document::utility_types::embedded_resources::EmbeddedResources;
|
||||
use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, PTZ};
|
||||
use crate::messages::portfolio::document::utility_types::network_interface::{FlowType, InputConnector, NodeTemplate, OutputConnector};
|
||||
use crate::messages::portfolio::utility_types::{CachedData, PanelType};
|
||||
@@ -29,13 +30,13 @@ use crate::messages::tool::tool_messages::tool_prelude::Key;
|
||||
use crate::messages::tool::utility_types::ToolType;
|
||||
use crate::node_graph_executor::NodeGraphExecutor;
|
||||
use glam::{DAffine2, DVec2};
|
||||
use graph_craft::application_io::{ResourceHash, wgpu_available};
|
||||
use graph_craft::descriptor;
|
||||
use graph_craft::document::value::TaggedValue;
|
||||
use graph_craft::document::{NodeId, NodeInput, NodeNetwork, OldNodeNetwork};
|
||||
use graphene_std::math::quad::Quad;
|
||||
use graphene_std::path_bool_nodes::boolean_intersect;
|
||||
use graphene_std::raster::BlendMode;
|
||||
use graphene_std::render_node::wgpu_available;
|
||||
use graphene_std::subpath::Subpath;
|
||||
use graphene_std::vector::PointId;
|
||||
use graphene_std::vector::click_target::{ClickTarget, ClickTargetType};
|
||||
@@ -59,6 +60,7 @@ pub struct DocumentMessageContext<'a> {
|
||||
pub layers_panel_open: bool,
|
||||
pub properties_panel_open: bool,
|
||||
pub viewport: &'a ViewportMessageHandler,
|
||||
pub resources: &'a ResourceMessageHandler,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, ExtractField)]
|
||||
@@ -107,6 +109,9 @@ pub struct DocumentMessageHandler {
|
||||
pub graph_view_overlay_open: bool,
|
||||
/// The current opacity of the faded node graph background that covers up the artwork.
|
||||
pub graph_fade_artwork_percentage: f64,
|
||||
/// Resources embedded in the document. Only propagated if saving to an external file and never for autosaved documents.
|
||||
#[serde(rename = "resources", default, skip_serializing_if = "EmbeddedResources::is_empty")]
|
||||
pub embedded_resources: EmbeddedResources,
|
||||
|
||||
// =============================================
|
||||
// Fields omitted from the saved document format
|
||||
@@ -169,6 +174,7 @@ impl Default for DocumentMessageHandler {
|
||||
graph_view_overlay_open: false,
|
||||
snapping_state: SnappingState::default(),
|
||||
graph_fade_artwork_percentage: 80.,
|
||||
embedded_resources: EmbeddedResources::default(),
|
||||
// =============================================
|
||||
// Fields omitted from the saved document format
|
||||
// =============================================
|
||||
@@ -200,6 +206,7 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||
data_panel_open,
|
||||
layers_panel_open,
|
||||
properties_panel_open,
|
||||
resources,
|
||||
} = context;
|
||||
|
||||
match message {
|
||||
@@ -916,15 +923,34 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||
if path.is_some() {
|
||||
responses.add(DocumentMessage::MarkAsSaved);
|
||||
}
|
||||
|
||||
let folder = self.path.as_ref().and_then(|path| path.parent()).map(|parent| parent.to_path_buf());
|
||||
|
||||
responses.add(FrontendMessage::TriggerSaveDocument {
|
||||
document_id,
|
||||
name: format!("{}.{}", self.name.clone(), FILE_EXTENSION),
|
||||
path,
|
||||
folder,
|
||||
content: self.serialize_document().into_bytes().into(),
|
||||
let resource_hashes = Vec::from_iter(self.used_resources(false)).into_boxed_slice();
|
||||
let resources = resources.resources();
|
||||
let mut document = self.clone();
|
||||
let name = format!("{}.{}", self.name.clone(), FILE_EXTENSION);
|
||||
|
||||
responses.add(FrontendMessage::Await {
|
||||
future: FrontendMessageFuture::new(async move {
|
||||
let loads = resource_hashes
|
||||
.into_iter()
|
||||
.map(|hash| {
|
||||
let resource = resources.load(hash);
|
||||
async move { resource.await.map(|resource| (hash, resource)) }
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
document.embedded_resources = EmbeddedResources::from_iter(futures::future::join_all(loads).await.into_iter().flatten());
|
||||
let content = document.serialize_document();
|
||||
|
||||
FrontendMessage::TriggerSaveDocument {
|
||||
document_id,
|
||||
name,
|
||||
path,
|
||||
folder,
|
||||
content: content.into_bytes().into(),
|
||||
}
|
||||
}),
|
||||
});
|
||||
}
|
||||
DocumentMessage::SavedDocument { path } => {
|
||||
@@ -1306,11 +1332,10 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||
let layer_text_frames = text_frames
|
||||
.into_iter()
|
||||
.filter(|(node_id, _)| self.network_interface.document_network().nodes.contains_key(node_id))
|
||||
.filter_map(|(node_id, frame)| {
|
||||
self.network_interface.is_layer(&node_id, &[]).then(|| {
|
||||
let layer = LayerNodeIdentifier::new(node_id, &self.network_interface);
|
||||
(layer, frame)
|
||||
})
|
||||
.filter(|&(node_id, _)| self.network_interface.is_layer(&node_id, &[]))
|
||||
.map(|(node_id, frame)| {
|
||||
let layer = LayerNodeIdentifier::new(node_id, &self.network_interface);
|
||||
(layer, frame)
|
||||
})
|
||||
.collect();
|
||||
self.network_interface.update_text_frames(layer_text_frames);
|
||||
@@ -2546,6 +2571,7 @@ impl DocumentMessageHandler {
|
||||
|
||||
/// Helper method for NudgeSelectedLayers message.
|
||||
/// Handles keyboard nudging of selected layers with optional resize mode.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_nudge_selected_layers(
|
||||
&mut self,
|
||||
delta_x: f64,
|
||||
@@ -3444,6 +3470,16 @@ impl DocumentMessageHandler {
|
||||
pub fn graph_view_overlay_open(&self) -> bool {
|
||||
self.graph_view_overlay_open
|
||||
}
|
||||
|
||||
pub fn used_resources(&self, include_history: bool) -> HashSet<ResourceHash> {
|
||||
let mut resources = HashSet::new();
|
||||
self.network_interface.collect_used_resources(&mut resources);
|
||||
if include_history {
|
||||
self.document_undo_history.iter().for_each(|interface| interface.collect_used_resources(&mut resources));
|
||||
self.document_redo_history.iter().for_each(|interface| interface.collect_used_resources(&mut resources));
|
||||
}
|
||||
resources
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a network interface with a single export
|
||||
|
||||
@@ -298,13 +298,18 @@ impl<'a> ModifyInputsContext<'a> {
|
||||
let transform = resolve_proto_node_type(graphene_std::transform_nodes::transform::IDENTIFIER)
|
||||
.expect("Transform node does not exist")
|
||||
.default_node_template();
|
||||
|
||||
let png_bytes: std::sync::Arc<[u8]> = image.to_png().into();
|
||||
let hash = graphene_std::application_io::ResourceHash::from(png_bytes.as_ref());
|
||||
self.responses.add(ResourceMessage::Store { data: png_bytes });
|
||||
|
||||
let image_node = resolve_proto_node_type(graphene_std::raster_nodes::std_nodes::image::IDENTIFIER)
|
||||
.expect("Image node does not exist")
|
||||
.node_template_input_override([Some(NodeInput::value(TaggedValue::None, false)), Some(NodeInput::value(TaggedValue::ImageData(image), false))]);
|
||||
.node_template_input_override([Some(NodeInput::value(TaggedValue::Resource(hash), false))]);
|
||||
|
||||
let image_id = NodeId::new();
|
||||
self.network_interface.insert_node(image_id, image_node, &[]);
|
||||
self.network_interface.move_node_to_chain_start(&image_id, layer, &[], self.import);
|
||||
let image_node_id = NodeId::new();
|
||||
self.network_interface.insert_node(image_node_id, image_node, &[]);
|
||||
self.network_interface.move_node_to_chain_start(&image_node_id, layer, &[], self.import);
|
||||
|
||||
let transform_id = NodeId::new();
|
||||
self.network_interface.insert_node(transform_id, transform, &[]);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use graph_craft::application_io::{Resource, ResourceHash};
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Clone, Default, Debug, PartialEq)]
|
||||
pub struct EmbeddedResources {
|
||||
resources: HashMap<ResourceHash, Resource>,
|
||||
}
|
||||
|
||||
impl EmbeddedResources {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.resources.is_empty()
|
||||
}
|
||||
|
||||
pub fn store(&mut self, resource: Resource) -> ResourceHash {
|
||||
let hash = ResourceHash::from(resource.as_ref());
|
||||
self.resources.insert(hash, resource);
|
||||
hash
|
||||
}
|
||||
}
|
||||
|
||||
impl FromIterator<(ResourceHash, Resource)> for EmbeddedResources {
|
||||
fn from_iter<T: IntoIterator<Item = (ResourceHash, Resource)>>(iter: T) -> Self {
|
||||
Self {
|
||||
resources: iter.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoIterator for EmbeddedResources {
|
||||
type Item = (ResourceHash, Resource);
|
||||
type IntoIter = std::collections::hash_map::IntoIter<ResourceHash, Resource>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.resources.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl serde::Serialize for EmbeddedResources {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
use serde::ser::SerializeMap;
|
||||
|
||||
let human_readable = serializer.is_human_readable();
|
||||
let mut map = serializer.serialize_map(Some(self.resources.len()))?;
|
||||
for (hash, resource) in &self.resources {
|
||||
let bytes: &[u8] = resource.as_ref();
|
||||
if human_readable {
|
||||
map.serialize_entry(hash, &BASE64.encode(bytes))?;
|
||||
} else {
|
||||
map.serialize_entry(hash, serde_bytes::Bytes::new(bytes))?;
|
||||
}
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for EmbeddedResources {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
struct EmbeddedResourcesVisitor {
|
||||
human_readable: bool,
|
||||
}
|
||||
|
||||
impl<'de> serde::de::Visitor<'de> for EmbeddedResourcesVisitor {
|
||||
type Value = EmbeddedResources;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("a map of ResourceHash to resource bytes")
|
||||
}
|
||||
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
|
||||
let mut resources = HashMap::with_capacity(map.size_hint().unwrap_or(0));
|
||||
while let Some(hash) = map.next_key::<ResourceHash>()? {
|
||||
let resource = if self.human_readable {
|
||||
let encoded: String = map.next_value()?;
|
||||
let bytes = BASE64.decode(&encoded).map_err(serde::de::Error::custom)?;
|
||||
Resource::new(bytes)
|
||||
} else {
|
||||
let bytes: serde_bytes::ByteBuf = map.next_value()?;
|
||||
Resource::new(bytes.into_vec())
|
||||
};
|
||||
resources.insert(hash, resource);
|
||||
}
|
||||
Ok(EmbeddedResources { resources })
|
||||
}
|
||||
}
|
||||
|
||||
let human_readable = deserializer.is_human_readable();
|
||||
deserializer.deserialize_map(EmbeddedResourcesVisitor { human_readable })
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod clipboards;
|
||||
pub mod document_metadata;
|
||||
pub mod embedded_resources;
|
||||
pub mod error;
|
||||
pub mod misc;
|
||||
pub mod network_interface;
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::messages::tool::tool_messages::tool_prelude::NumberInputMode;
|
||||
use deserialization::deserialize_node_persistent_metadata;
|
||||
use glam::{DAffine2, DVec2, IVec2};
|
||||
use graph_craft::Type;
|
||||
use graph_craft::application_io::ResourceHash;
|
||||
use graph_craft::document::value::TaggedValue;
|
||||
use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput, NodeNetwork, OldDocumentNodeImplementation, OldNodeNetwork};
|
||||
use graphene_std::ContextDependencies;
|
||||
@@ -510,6 +511,10 @@ impl NodeNetworkInterface {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn collect_used_resources(&self, target: &mut HashSet<ResourceHash>) {
|
||||
collect_network_resources(self.document_network(), target);
|
||||
}
|
||||
|
||||
pub fn frontend_imports(&mut self, network_path: &[NodeId]) -> Vec<Option<FrontendGraphOutput>> {
|
||||
match network_path.split_last() {
|
||||
Some((node_id, encapsulating_network_path)) => {
|
||||
@@ -6769,6 +6774,21 @@ pub enum TransactionStatus {
|
||||
Finished,
|
||||
}
|
||||
|
||||
fn collect_network_resources(network: &NodeNetwork, out: &mut HashSet<ResourceHash>) {
|
||||
for node in network.nodes.values() {
|
||||
for input in &node.inputs {
|
||||
if let NodeInput::Value { tagged_value, .. } = input
|
||||
&& let TaggedValue::Resource(hash) = &**tagged_value
|
||||
{
|
||||
out.insert(*hash);
|
||||
}
|
||||
}
|
||||
if let DocumentNodeImplementation::Network(nested) = &node.implementation {
|
||||
collect_network_resources(nested, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod network_interface_tests {
|
||||
use crate::test_utils::test_prelude::*;
|
||||
|
||||
@@ -1588,12 +1588,24 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
|
||||
document.network_interface.set_input(&InputConnector::node(*node_id, 4), old_inputs[3].clone(), network_path);
|
||||
}
|
||||
|
||||
if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster_nodes::std_nodes::image::IDENTIFIER) && inputs_count == 1 {
|
||||
let mut node_template = resolve_document_node_type(&reference)?.default_node_template();
|
||||
document.network_interface.replace_implementation(node_id, network_path, &mut node_template);
|
||||
// Upgrade `image` nodes that stored image data as `Image<Color>`` to `image` nodes that store a reference to the image data as a resource.
|
||||
// Encodes the image data as PNG and stores it in the document's embedded resources and rewires the node to reference that resource.
|
||||
if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster_nodes::std_nodes::image::IDENTIFIER) {
|
||||
let image = node.inputs.iter().find_map(|input| match input.as_value()? {
|
||||
TaggedValue::ImageData(image) => Some(image.clone()),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
// Insert a new empty input for the image
|
||||
document.network_interface.add_import(TaggedValue::None, false, 0, "Empty", "", &[*node_id]);
|
||||
if let Some(image) = image {
|
||||
let hash = document.embedded_resources.store(graphene_std::application_io::Resource::new(image.to_png()));
|
||||
|
||||
let mut node_template = resolve_document_node_type(&reference)?.default_node_template();
|
||||
document.network_interface.replace_implementation(node_id, network_path, &mut node_template);
|
||||
let _ = document.network_interface.replace_inputs(node_id, network_path, &mut node_template);
|
||||
document
|
||||
.network_interface
|
||||
.set_input(&InputConnector::node(*node_id, 0), NodeInput::value(TaggedValue::Resource(hash), false), network_path);
|
||||
}
|
||||
}
|
||||
|
||||
if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster_nodes::std_nodes::noise_pattern::IDENTIFIER) && inputs_count == 15 {
|
||||
|
||||
@@ -50,6 +50,7 @@ pub enum PortfolioMessage {
|
||||
},
|
||||
DestroyAllDocuments,
|
||||
EditorPreferences,
|
||||
GarbageCollectResources,
|
||||
FontCatalogLoaded {
|
||||
catalog: FontCatalog,
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::messages::tool::utility_types::{HintData, ToolType};
|
||||
use crate::messages::viewport::ToPhysical;
|
||||
use crate::node_graph_executor::{ExportConfig, NodeGraphExecutor};
|
||||
use glam::{DAffine2, DVec2};
|
||||
use graph_craft::application_io::ResourceHash;
|
||||
use graph_craft::document::NodeId;
|
||||
use graphene_std::Color;
|
||||
use graphene_std::raster_types::Image;
|
||||
@@ -35,6 +36,7 @@ use graphene_std::text::Font;
|
||||
use graphene_std::vector::misc::HandleId;
|
||||
use graphene_std::vector::{PointId, SegmentId, Vector, VectorModificationType};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::vec;
|
||||
|
||||
#[derive(ExtractField)]
|
||||
@@ -46,6 +48,7 @@ pub struct PortfolioMessageContext<'a> {
|
||||
pub reset_node_definitions_on_open: bool,
|
||||
pub timing_information: TimingInformation,
|
||||
pub viewport: &'a ViewportMessageHandler,
|
||||
pub resources: &'a ResourceMessageHandler,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, ExtractField)]
|
||||
@@ -74,6 +77,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
reset_node_definitions_on_open,
|
||||
timing_information,
|
||||
viewport,
|
||||
resources,
|
||||
} = context;
|
||||
|
||||
match message {
|
||||
@@ -90,6 +94,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
current_tool,
|
||||
preferences,
|
||||
viewport,
|
||||
resources,
|
||||
data_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Data) && !self.workspace_panel_layout.focus_document,
|
||||
layers_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Layers) && !self.workspace_panel_layout.focus_document,
|
||||
properties_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Properties) && !self.workspace_panel_layout.focus_document,
|
||||
@@ -159,6 +164,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
current_tool,
|
||||
preferences,
|
||||
viewport,
|
||||
resources,
|
||||
data_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Data) && !self.workspace_panel_layout.focus_document,
|
||||
layers_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Layers) && !self.workspace_panel_layout.focus_document,
|
||||
properties_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Properties) && !self.workspace_panel_layout.focus_document,
|
||||
@@ -183,6 +189,8 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
responses.add(PortfolioMessage::AutoSaveDocument { document_id: *document_id });
|
||||
}
|
||||
}
|
||||
|
||||
responses.add(PortfolioMessage::GarbageCollectResources);
|
||||
}
|
||||
PortfolioMessage::AutoSaveDocument { document_id } => {
|
||||
let Some(document) = self.document(document_id) else { return };
|
||||
@@ -437,6 +445,23 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
}
|
||||
}
|
||||
PortfolioMessage::EditorPreferences => self.executor.update_editor_preferences(preferences.editor_preferences()),
|
||||
PortfolioMessage::GarbageCollectResources => {
|
||||
let mut used_resources = HashSet::new();
|
||||
for (id, info) in self.unloaded_documents.iter() {
|
||||
if let Some(resources) = &info.resources {
|
||||
used_resources.extend(resources.iter());
|
||||
} else {
|
||||
responses.add(PersistentStateMessage::ReadDocument { document_id: *id });
|
||||
return;
|
||||
}
|
||||
}
|
||||
for document in self.documents.values() {
|
||||
used_resources.extend(document.used_resources(true));
|
||||
}
|
||||
responses.add(ResourceMessage::GarbageCollect {
|
||||
used: Vec::from_iter(used_resources).into_boxed_slice(),
|
||||
});
|
||||
}
|
||||
PortfolioMessage::LoadDocumentResources { document_id } => {
|
||||
let catalog = &self.cached_data.font_catalog;
|
||||
|
||||
@@ -758,6 +783,16 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
// Upgrade the document's nodes to be compatible with the latest version
|
||||
document_migration_upgrades(&mut document, reset_node_definitions_on_open);
|
||||
|
||||
// Load the document's embedded resources into the resource storage
|
||||
std::mem::take(&mut document.embedded_resources).into_iter().for_each(|(hash, resource)| {
|
||||
let data: Arc<[u8]> = Arc::from(resource.as_ref());
|
||||
if ResourceHash::from(data.as_ref()) != hash {
|
||||
log::error!("Resource hash mismatch for resource with hash {hash}");
|
||||
return;
|
||||
}
|
||||
responses.add(ResourceMessage::Store { data });
|
||||
});
|
||||
|
||||
// Ensure each node has the metadata for its inputs
|
||||
for (node_id, node, path) in document.network_interface.document_network().clone().recursive_nodes() {
|
||||
document.network_interface.validate_input_metadata(node_id, node, &path);
|
||||
@@ -1869,6 +1904,7 @@ impl PortfolioMessageHandler {
|
||||
name: document.name.clone(),
|
||||
path: document.path.clone(),
|
||||
is_saved: document.is_saved(),
|
||||
resources: Some(document.used_resources(false).into_iter().collect()),
|
||||
})
|
||||
} else {
|
||||
self.unloaded_documents.get(&document_id).cloned()
|
||||
|
||||
@@ -15,7 +15,7 @@ pub use crate::messages::dialog::export_dialog::{ExportDialogMessage, ExportDial
|
||||
pub use crate::messages::dialog::new_document_dialog::{NewDocumentDialogMessage, NewDocumentDialogMessageDiscriminant, NewDocumentDialogMessageHandler};
|
||||
pub use crate::messages::dialog::preferences_dialog::{PreferencesDialogMessage, PreferencesDialogMessageContext, PreferencesDialogMessageDiscriminant, PreferencesDialogMessageHandler};
|
||||
pub use crate::messages::dialog::{DialogMessage, DialogMessageContext, DialogMessageDiscriminant, DialogMessageHandler};
|
||||
pub use crate::messages::frontend::{FrontendMessage, FrontendMessageDiscriminant};
|
||||
pub use crate::messages::frontend::{FrontendMessage, FrontendMessageDiscriminant, FrontendMessageFuture};
|
||||
pub use crate::messages::input_mapper::key_mapping::{KeyMappingMessage, KeyMappingMessageContext, KeyMappingMessageDiscriminant, KeyMappingMessageHandler};
|
||||
pub use crate::messages::input_mapper::{InputMapperMessage, InputMapperMessageContext, InputMapperMessageDiscriminant, InputMapperMessageHandler};
|
||||
pub use crate::messages::input_preprocessor::{InputPreprocessorMessage, InputPreprocessorMessageContext, InputPreprocessorMessageDiscriminant, InputPreprocessorMessageHandler};
|
||||
@@ -31,6 +31,7 @@ pub use crate::messages::portfolio::document::{DocumentMessage, DocumentMessageC
|
||||
pub use crate::messages::portfolio::persistent_state::{PersistentStateMessage, PersistentStateMessageContext, PersistentStateMessageDiscriminant, PersistentStateMessageHandler};
|
||||
pub use crate::messages::portfolio::{PortfolioMessage, PortfolioMessageContext, PortfolioMessageDiscriminant, PortfolioMessageHandler};
|
||||
pub use crate::messages::preferences::{PreferencesMessage, PreferencesMessageDiscriminant, PreferencesMessageHandler};
|
||||
pub use crate::messages::resource::{ResourceMessage, ResourceMessageContext, ResourceMessageDiscriminant, ResourceMessageHandler};
|
||||
pub use crate::messages::tool::transform_layer::{TransformLayerMessage, TransformLayerMessageDiscriminant, TransformLayerMessageHandler};
|
||||
pub use crate::messages::tool::{ToolMessage, ToolMessageContext, ToolMessageDiscriminant, ToolMessageHandler};
|
||||
pub use crate::messages::viewport::{ViewportMessage, ViewportMessageDiscriminant, ViewportMessageHandler};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
mod resource_message;
|
||||
mod resource_message_handler;
|
||||
|
||||
#[doc(inline)]
|
||||
pub use resource_message::{ResourceMessage, ResourceMessageDiscriminant};
|
||||
#[doc(inline)]
|
||||
pub use resource_message_handler::{ResourceMessageContext, ResourceMessageHandler, ResourcesHandle};
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::messages::prelude::*;
|
||||
use graph_craft::application_io::ResourceHash;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[impl_message(Message, Resource)]
|
||||
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub enum ResourceMessage {
|
||||
Store { data: Arc<[u8]> },
|
||||
GarbageCollect { used: Box<[ResourceHash]> },
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use crate::messages::prelude::*;
|
||||
use graph_craft::application_io::{LoadResource, ResourceFuture, ResourceHash, ResourceStorage};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ResourcesHandle {
|
||||
inner: Arc<RwLock<Box<dyn ResourceStorage>>>,
|
||||
}
|
||||
|
||||
impl LoadResource for ResourcesHandle {
|
||||
fn load(&self, hash: ResourceHash) -> ResourceFuture {
|
||||
let guard = self.inner.read().unwrap();
|
||||
guard.load(hash)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(ExtractField)]
|
||||
pub struct ResourceMessageHandler {
|
||||
storage: Option<Arc<RwLock<Box<dyn ResourceStorage>>>>,
|
||||
}
|
||||
|
||||
impl ResourceMessageHandler {
|
||||
pub fn new(resource_storage: Box<dyn ResourceStorage>) -> Self {
|
||||
Self {
|
||||
storage: Some(Arc::new(RwLock::new(resource_storage))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resources(&self) -> Box<dyn LoadResource> {
|
||||
Box::new(ResourcesHandle {
|
||||
inner: self.storage.clone().expect("Resource storage not initialized"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ResourceMessageHandler {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ResourceMessageHandler").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ResourceMessageHandler {
|
||||
#[cfg(not(test))]
|
||||
fn default() -> Self {
|
||||
Self { storage: None }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
storage: Some(Arc::new(RwLock::new(Box::new(graph_craft::application_io::HashMapResourceStorage::new())))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(ExtractField)]
|
||||
pub struct ResourceMessageContext {}
|
||||
|
||||
#[message_handler_data]
|
||||
impl MessageHandler<ResourceMessage, ResourceMessageContext> for ResourceMessageHandler {
|
||||
fn process_message(&mut self, message: ResourceMessage, _responses: &mut VecDeque<Message>, _context: ResourceMessageContext) {
|
||||
let Some(storage) = &self.storage else {
|
||||
log::error!("Received resource message but storage is not initialized");
|
||||
return;
|
||||
};
|
||||
let mut storage = storage.write().unwrap();
|
||||
|
||||
match message {
|
||||
ResourceMessage::Store { data } => {
|
||||
let _hash = storage.store(data.as_ref());
|
||||
}
|
||||
ResourceMessage::GarbageCollect { used } => {
|
||||
storage.garbage_collect(&used);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
advertise_actions!(ResourceMessageDiscriminant;);
|
||||
}
|
||||
@@ -97,7 +97,7 @@ pub struct ExportConfig {
|
||||
struct InternalNodeGraphUpdateSender(Sender<NodeGraphUpdate>);
|
||||
|
||||
impl InternalNodeGraphUpdateSender {
|
||||
fn send_generation_response(&self, response: CompilationResponse) {
|
||||
fn send_compilation_response(&self, response: CompilationResponse) {
|
||||
self.0.send(NodeGraphUpdate::CompilationResponse(response)).expect("Failed to send response")
|
||||
}
|
||||
|
||||
@@ -134,7 +134,11 @@ impl NodeRuntime {
|
||||
editor_preferences: Box::new(EditorPreferences::default()),
|
||||
node_graph_message_sender: Box::new(InternalNodeGraphUpdateSender(sender)),
|
||||
|
||||
#[cfg(not(test))]
|
||||
application_io: None,
|
||||
|
||||
#[cfg(test)]
|
||||
application_io: Some(PlatformApplicationIo::default().into()),
|
||||
}
|
||||
.into(),
|
||||
|
||||
@@ -154,19 +158,6 @@ impl NodeRuntime {
|
||||
}
|
||||
|
||||
pub async fn run(&mut self) -> Option<ImageTexture> {
|
||||
if self.editor_api.application_io.is_none() {
|
||||
self.editor_api = PlatformEditorApi {
|
||||
#[cfg(all(not(test), target_family = "wasm"))]
|
||||
application_io: Some(PlatformApplicationIo::new().await.into()),
|
||||
#[cfg(any(test, not(target_family = "wasm")))]
|
||||
application_io: Some(PlatformApplicationIo::new().await.into()),
|
||||
font_cache: self.editor_api.font_cache.clone(),
|
||||
node_graph_message_sender: Box::new(self.sender.clone()),
|
||||
editor_preferences: Box::new(self.editor_preferences.clone()),
|
||||
}
|
||||
.into();
|
||||
}
|
||||
|
||||
let mut font = None;
|
||||
let mut preferences = None;
|
||||
let mut graph = None;
|
||||
@@ -247,7 +238,7 @@ impl NodeRuntime {
|
||||
|
||||
self.update_thumbnails = true;
|
||||
|
||||
self.sender.send_generation_response(CompilationResponse { result, node_graph_errors });
|
||||
self.sender.send_compilation_response(CompilationResponse { result, node_graph_errors });
|
||||
}
|
||||
GraphRuntimeRequest::ExecutionRequest(ExecutionRequest { execution_id, mut render_config, .. }) => {
|
||||
// We may want to render via the SVG pipeline even though raster was requested, if SVG Preview render mode is active or WebGPU/Vello is unavailable
|
||||
@@ -571,18 +562,24 @@ pub async fn run_node_graph() -> (bool, Option<ImageTexture>) {
|
||||
(false, None)
|
||||
}
|
||||
|
||||
pub async fn replace_node_runtime(runtime: NodeRuntime) -> Option<NodeRuntime> {
|
||||
pub fn replace_node_runtime(runtime: NodeRuntime) -> Option<NodeRuntime> {
|
||||
let mut node_runtime = NODE_RUNTIME.lock();
|
||||
node_runtime.replace(runtime)
|
||||
}
|
||||
pub async fn replace_application_io(application_io: PlatformApplicationIo) {
|
||||
pub(crate) fn replace_application_io(application_io: PlatformApplicationIo) {
|
||||
let mut node_runtime = NODE_RUNTIME.lock();
|
||||
if let Some(node_runtime) = &mut *node_runtime {
|
||||
node_runtime.editor_api = PlatformEditorApi {
|
||||
font_cache: node_runtime.editor_api.font_cache.clone(),
|
||||
node_runtime.replace_application_io(application_io);
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeRuntime {
|
||||
pub(crate) fn replace_application_io(&mut self, application_io: PlatformApplicationIo) {
|
||||
self.editor_api = PlatformEditorApi {
|
||||
font_cache: self.editor_api.font_cache.clone(),
|
||||
application_io: Some(application_io.into()),
|
||||
node_graph_message_sender: Box::new(node_runtime.sender.clone()),
|
||||
editor_preferences: Box::new(node_runtime.editor_preferences.clone()),
|
||||
node_graph_message_sender: Box::new(self.sender.clone()),
|
||||
editor_preferences: Box::new(self.editor_preferences.clone()),
|
||||
}
|
||||
.into();
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ impl NodeRuntimeIO {
|
||||
pub fn new() -> Self {
|
||||
let (response_sender, response_receiver) = std::sync::mpsc::channel();
|
||||
let (request_sender, request_receiver) = std::sync::mpsc::channel();
|
||||
futures::executor::block_on(replace_node_runtime(NodeRuntime::new(request_receiver, response_sender)));
|
||||
replace_node_runtime(NodeRuntime::new(request_receiver, response_sender));
|
||||
|
||||
Self {
|
||||
sender: request_sender,
|
||||
|
||||
Reference in New Issue
Block a user