Merge branch 'master' into pr/atty303/1349
This commit is contained in:
commit
77d53df532
147 files changed
+2408
-6154
No files matched your search
@@ -30,166 +30,312 @@ impl ToTokens for InlinePropsDeserializerOutput {
|
||||
impl DeserializerArgs<InlinePropsDeserializerOutput> for InlinePropsDeserializerArgs {
|
||||
fn to_output(&self, component_body: &ComponentBody) -> Result<InlinePropsDeserializerOutput> {
|
||||
Ok(InlinePropsDeserializerOutput {
|
||||
comp_fn: Self::get_function(component_body),
|
||||
props_struct: Self::get_props_struct(component_body),
|
||||
comp_fn: get_function(component_body),
|
||||
props_struct: get_props_struct(component_body),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl InlinePropsDeserializerArgs {
|
||||
fn get_props_struct(component_body: &ComponentBody) -> ItemStruct {
|
||||
let ComponentBody { item_fn, .. } = component_body;
|
||||
let ItemFn { vis, sig, .. } = item_fn;
|
||||
let Signature {
|
||||
inputs,
|
||||
ident: fn_ident,
|
||||
generics,
|
||||
..
|
||||
} = sig;
|
||||
fn get_props_struct(component_body: &ComponentBody) -> ItemStruct {
|
||||
let ComponentBody { item_fn, .. } = component_body;
|
||||
let ItemFn { vis, sig, .. } = item_fn;
|
||||
let Signature {
|
||||
inputs,
|
||||
ident: fn_ident,
|
||||
generics,
|
||||
..
|
||||
} = sig;
|
||||
|
||||
// Skip first arg since that's the context
|
||||
let struct_fields = inputs.iter().skip(1).map(move |f| {
|
||||
match f {
|
||||
FnArg::Receiver(_) => unreachable!(), // Unreachable because of ComponentBody parsing
|
||||
FnArg::Typed(pt) => {
|
||||
let arg_pat = &pt.pat; // Pattern (identifier)
|
||||
let arg_colon = &pt.colon_token;
|
||||
let arg_ty = &pt.ty; // Type
|
||||
let arg_attrs = &pt.attrs; // Attributes
|
||||
// Skip first arg since that's the context
|
||||
let struct_fields = inputs.iter().skip(1).map(move |f| {
|
||||
match f {
|
||||
FnArg::Receiver(_) => unreachable!(), // Unreachable because of ComponentBody parsing
|
||||
FnArg::Typed(pt) => {
|
||||
let arg_pat = &pt.pat; // Pattern (identifier)
|
||||
let arg_colon = &pt.colon_token;
|
||||
let arg_ty = &pt.ty; // Type
|
||||
let arg_attrs = &pt.attrs; // Attributes
|
||||
|
||||
quote! {
|
||||
#(#arg_attrs)
|
||||
*
|
||||
#vis #arg_pat #arg_colon #arg_ty
|
||||
}
|
||||
quote! {
|
||||
#(#arg_attrs)
|
||||
*
|
||||
#vis #arg_pat #arg_colon #arg_ty
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let struct_ident = Ident::new(&format!("{fn_ident}Props"), fn_ident.span());
|
||||
|
||||
let first_lifetime = if let Some(GenericParam::Lifetime(lt)) = generics.params.first() {
|
||||
Some(lt)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let struct_attrs = if first_lifetime.is_some() {
|
||||
quote! { #[derive(Props)] }
|
||||
} else {
|
||||
quote! { #[derive(Props, PartialEq)] }
|
||||
};
|
||||
|
||||
let struct_generics = if first_lifetime.is_some() {
|
||||
let struct_generics: Punctuated<GenericParam, Comma> = component_body
|
||||
.item_fn
|
||||
.sig
|
||||
.generics
|
||||
.params
|
||||
.iter()
|
||||
.map(|it| match it {
|
||||
GenericParam::Type(tp) => {
|
||||
let mut tp = tp.clone();
|
||||
tp.bounds.push(parse_quote!( 'a ));
|
||||
|
||||
GenericParam::Type(tp)
|
||||
}
|
||||
_ => it.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
quote! { <#struct_generics> }
|
||||
} else {
|
||||
quote! { #generics }
|
||||
};
|
||||
|
||||
parse_quote! {
|
||||
#struct_attrs
|
||||
#[allow(non_camel_case_types)]
|
||||
#vis struct #struct_ident #struct_generics
|
||||
{
|
||||
#(#struct_fields),*
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
fn get_function(component_body: &ComponentBody) -> ItemFn {
|
||||
let ComponentBody {
|
||||
item_fn,
|
||||
cx_pat_type,
|
||||
..
|
||||
} = component_body;
|
||||
let ItemFn {
|
||||
attrs: fn_attrs,
|
||||
vis,
|
||||
sig,
|
||||
block: fn_block,
|
||||
} = item_fn;
|
||||
let Signature {
|
||||
inputs,
|
||||
ident: fn_ident,
|
||||
generics,
|
||||
output: fn_output,
|
||||
asyncness,
|
||||
..
|
||||
} = sig;
|
||||
let Generics { where_clause, .. } = generics;
|
||||
let struct_ident = Ident::new(&format!("{fn_ident}Props"), fn_ident.span());
|
||||
|
||||
let cx_pat = &cx_pat_type.pat;
|
||||
let struct_ident = Ident::new(&format!("{fn_ident}Props"), fn_ident.span());
|
||||
let first_lifetime = if let Some(GenericParam::Lifetime(lt)) = generics.params.first() {
|
||||
Some(lt)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Skip first arg since that's the context
|
||||
let struct_field_names = inputs.iter().skip(1).filter_map(|f| match f {
|
||||
FnArg::Receiver(_) => unreachable!(), // ComponentBody prohibits receiver parameters.
|
||||
FnArg::Typed(t) => Some(&t.pat),
|
||||
});
|
||||
let struct_attrs = if first_lifetime.is_some() {
|
||||
quote! { #[derive(Props)] }
|
||||
} else {
|
||||
quote! { #[derive(Props, PartialEq)] }
|
||||
};
|
||||
|
||||
let first_lifetime = if let Some(GenericParam::Lifetime(lt)) = generics.params.first() {
|
||||
Some(lt)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let struct_generics = if first_lifetime.is_some() {
|
||||
let struct_generics: Punctuated<GenericParam, Comma> = component_body
|
||||
.item_fn
|
||||
.sig
|
||||
.generics
|
||||
.params
|
||||
.iter()
|
||||
.map(|it| match it {
|
||||
GenericParam::Type(tp) => {
|
||||
let mut tp = tp.clone();
|
||||
tp.bounds.push(parse_quote!( 'a ));
|
||||
|
||||
let (scope_lifetime, fn_generics) = if let Some(lt) = first_lifetime {
|
||||
(quote! { #lt, }, generics.clone())
|
||||
} else {
|
||||
let lifetime: LifetimeParam = parse_quote! { 'a };
|
||||
GenericParam::Type(tp)
|
||||
}
|
||||
_ => it.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut fn_generics = generics.clone();
|
||||
fn_generics
|
||||
.params
|
||||
.insert(0, GenericParam::Lifetime(lifetime.clone()));
|
||||
quote! { <#struct_generics> }
|
||||
} else {
|
||||
quote! { #generics }
|
||||
};
|
||||
|
||||
(quote! { #lifetime, }, fn_generics)
|
||||
};
|
||||
|
||||
let generics_no_bounds = {
|
||||
let mut generics = generics.clone();
|
||||
generics.params = generics
|
||||
.params
|
||||
.iter()
|
||||
.map(|it| match it {
|
||||
GenericParam::Type(tp) => {
|
||||
let mut tp = tp.clone();
|
||||
tp.bounds.clear();
|
||||
|
||||
GenericParam::Type(tp)
|
||||
}
|
||||
_ => it.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
generics
|
||||
};
|
||||
|
||||
parse_quote! {
|
||||
#(#fn_attrs)*
|
||||
#asyncness #vis fn #fn_ident #fn_generics (#cx_pat: Scope<#scope_lifetime #struct_ident #generics_no_bounds>) #fn_output
|
||||
#where_clause
|
||||
{
|
||||
let #struct_ident { #(#struct_field_names),* } = &#cx_pat.props;
|
||||
#fn_block
|
||||
}
|
||||
parse_quote! {
|
||||
#struct_attrs
|
||||
#[allow(non_camel_case_types)]
|
||||
#vis struct #struct_ident #struct_generics
|
||||
{
|
||||
#(#struct_fields),*
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_props_docs(fn_ident: &Ident, inputs: Vec<&FnArg>) -> Vec<Attribute> {
|
||||
if inputs.len() <= 1 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let arg_docs = inputs
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
FnArg::Receiver(_) => unreachable!(), // ComponentBody prohibits receiver parameters.
|
||||
FnArg::Typed(pt) => {
|
||||
let arg_doc = pt
|
||||
.attrs
|
||||
.iter()
|
||||
.filter_map(|attr| {
|
||||
// TODO: Error reporting
|
||||
// Check if the path of the attribute is "doc"
|
||||
if !is_attr_doc(attr) {
|
||||
return None;
|
||||
};
|
||||
|
||||
let Meta::NameValue(meta_name_value) = &attr.meta else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let Expr::Lit(doc_lit) = &meta_name_value.value else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let Lit::Str(doc_lit_str) = &doc_lit.lit else {
|
||||
return None;
|
||||
};
|
||||
|
||||
Some(doc_lit_str.value())
|
||||
})
|
||||
.fold(String::new(), |mut doc, next_doc_line| {
|
||||
doc.push('\n');
|
||||
doc.push_str(&next_doc_line);
|
||||
doc
|
||||
});
|
||||
|
||||
Some((
|
||||
&pt.pat,
|
||||
&pt.ty,
|
||||
pt.attrs.iter().find_map(|attr| {
|
||||
if attr.path() != &parse_quote!(deprecated) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let res = crate::utils::DeprecatedAttribute::from_meta(&attr.meta);
|
||||
|
||||
match res {
|
||||
Err(e) => panic!("{}", e.to_string()),
|
||||
Ok(v) => Some(v),
|
||||
}
|
||||
}),
|
||||
arg_doc,
|
||||
))
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut props_docs = Vec::with_capacity(5);
|
||||
let props_def_link = fn_ident.to_string() + "Props";
|
||||
let header =
|
||||
format!("# Props\n*For details, see the [props struct definition]({props_def_link}).*");
|
||||
|
||||
props_docs.push(parse_quote! {
|
||||
#[doc = #header]
|
||||
});
|
||||
|
||||
for (arg_name, arg_type, deprecation, input_arg_doc) in arg_docs {
|
||||
let arg_name = arg_name.into_token_stream().to_string();
|
||||
let arg_type = crate::utils::format_type_string(arg_type);
|
||||
|
||||
let input_arg_doc = keep_up_to_n_consecutive_chars(input_arg_doc.trim(), 2, '\n')
|
||||
.replace("\n\n", "</p><p>");
|
||||
let prop_def_link = format!("{props_def_link}::{arg_name}");
|
||||
let mut arg_doc = format!("- [`{arg_name}`]({prop_def_link}) : `{arg_type}`");
|
||||
|
||||
if let Some(deprecation) = deprecation {
|
||||
arg_doc.push_str("<p>👎 Deprecated");
|
||||
|
||||
if let Some(since) = deprecation.since {
|
||||
arg_doc.push_str(&format!(" since {since}"));
|
||||
}
|
||||
|
||||
if let Some(note) = deprecation.note {
|
||||
let note = keep_up_to_n_consecutive_chars(¬e, 1, '\n').replace('\n', " ");
|
||||
let note = keep_up_to_n_consecutive_chars(¬e, 1, '\t').replace('\t', " ");
|
||||
|
||||
arg_doc.push_str(&format!(": {note}"));
|
||||
}
|
||||
|
||||
arg_doc.push_str("</p>");
|
||||
|
||||
if !input_arg_doc.is_empty() {
|
||||
arg_doc.push_str("<hr/>");
|
||||
}
|
||||
}
|
||||
|
||||
if !input_arg_doc.is_empty() {
|
||||
arg_doc.push_str(&format!("<p>{input_arg_doc}</p>"));
|
||||
}
|
||||
|
||||
props_docs.push(parse_quote! {
|
||||
#[doc = #arg_doc]
|
||||
});
|
||||
}
|
||||
|
||||
props_docs
|
||||
}
|
||||
|
||||
fn get_function(component_body: &ComponentBody) -> ItemFn {
|
||||
let ComponentBody {
|
||||
item_fn,
|
||||
cx_pat_type,
|
||||
..
|
||||
} = component_body;
|
||||
let ItemFn {
|
||||
attrs: fn_attrs,
|
||||
vis,
|
||||
sig,
|
||||
block: fn_block,
|
||||
} = item_fn;
|
||||
let Signature {
|
||||
inputs,
|
||||
ident: fn_ident,
|
||||
generics,
|
||||
output: fn_output,
|
||||
asyncness,
|
||||
..
|
||||
} = sig;
|
||||
let Generics { where_clause, .. } = generics;
|
||||
|
||||
let cx_pat = &cx_pat_type.pat;
|
||||
let struct_ident = Ident::new(&format!("{fn_ident}Props"), fn_ident.span());
|
||||
|
||||
// Skip first arg since that's the context
|
||||
let struct_field_names = inputs.iter().skip(1).filter_map(|f| match f {
|
||||
FnArg::Receiver(_) => unreachable!(), // ComponentBody prohibits receiver parameters.
|
||||
FnArg::Typed(pt) => Some(&pt.pat),
|
||||
});
|
||||
|
||||
let first_lifetime = if let Some(GenericParam::Lifetime(lt)) = generics.params.first() {
|
||||
Some(lt)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (scope_lifetime, fn_generics) = if let Some(lt) = first_lifetime {
|
||||
(quote! { #lt, }, generics.clone())
|
||||
} else {
|
||||
let lifetime: LifetimeParam = parse_quote! { 'a };
|
||||
|
||||
let mut fn_generics = generics.clone();
|
||||
fn_generics
|
||||
.params
|
||||
.insert(0, GenericParam::Lifetime(lifetime.clone()));
|
||||
|
||||
(quote! { #lifetime, }, fn_generics)
|
||||
};
|
||||
|
||||
let generics_no_bounds = {
|
||||
let mut generics = generics.clone();
|
||||
generics.params = generics
|
||||
.params
|
||||
.iter()
|
||||
.map(|it| match it {
|
||||
GenericParam::Type(tp) => {
|
||||
let mut tp = tp.clone();
|
||||
tp.bounds.clear();
|
||||
|
||||
GenericParam::Type(tp)
|
||||
}
|
||||
_ => it.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
generics
|
||||
};
|
||||
|
||||
let props_docs = get_props_docs(fn_ident, inputs.iter().skip(1).collect());
|
||||
|
||||
parse_quote! {
|
||||
#(#fn_attrs)*
|
||||
#(#props_docs)*
|
||||
#asyncness #vis fn #fn_ident #fn_generics (#cx_pat: Scope<#scope_lifetime #struct_ident #generics_no_bounds>) #fn_output
|
||||
#where_clause
|
||||
{
|
||||
let #struct_ident { #(#struct_field_names),* } = &#cx_pat.props;
|
||||
#fn_block
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if the attribute is a `#[doc]` attribute.
|
||||
fn is_attr_doc(attr: &Attribute) -> bool {
|
||||
attr.path() == &parse_quote!(doc)
|
||||
}
|
||||
|
||||
fn keep_up_to_n_consecutive_chars(
|
||||
input: &str,
|
||||
n_of_consecutive_chars_allowed: usize,
|
||||
target_char: char,
|
||||
) -> String {
|
||||
let mut output = String::new();
|
||||
let mut prev_char: Option<char> = None;
|
||||
let mut consecutive_count = 0;
|
||||
|
||||
for c in input.chars() {
|
||||
match prev_char {
|
||||
Some(prev) if c == target_char && prev == target_char => {
|
||||
if consecutive_count < n_of_consecutive_chars_allowed {
|
||||
output.push(c);
|
||||
consecutive_count += 1;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
output.push(c);
|
||||
prev_char = Some(c);
|
||||
consecutive_count = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
#![doc(html_logo_url = "https://avatars.githubusercontent.com/u/79236386")]
|
||||
#![doc(html_favicon_url = "https://avatars.githubusercontent.com/u/79236386")]
|
||||
|
||||
use proc_macro::TokenStream;
|
||||
use quote::ToTokens;
|
||||
use rsx::RenderCallBody;
|
||||
@@ -8,6 +12,7 @@ use syn::{parse_macro_input, Path, Token};
|
||||
mod component_body;
|
||||
mod component_body_deserializers;
|
||||
mod props;
|
||||
mod utils;
|
||||
|
||||
// mod rsx;
|
||||
use crate::component_body::ComponentBody;
|
||||
|
||||
@@ -935,6 +935,14 @@ Finally, call `.build()` to create the instance of `{name}`.
|
||||
}
|
||||
|
||||
pub fn field_impl(&self, field: &FieldInfo) -> Result<TokenStream, Error> {
|
||||
let FieldInfo {
|
||||
name: field_name,
|
||||
ty: field_type,
|
||||
..
|
||||
} = field;
|
||||
if *field_name == "key" {
|
||||
return Err(Error::new_spanned(field_name, "Naming a prop `key` is not allowed because the name can conflict with the built in key attribute. See https://dioxuslabs.com/learn/0.4/reference/dynamic_rendering#rendering-lists for more information about keys"));
|
||||
}
|
||||
let StructInfo {
|
||||
ref builder_name, ..
|
||||
} = *self;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
use quote::ToTokens;
|
||||
use syn::parse::{Parse, ParseStream};
|
||||
use syn::spanned::Spanned;
|
||||
use syn::{parse_quote, Expr, Lit, Meta, Token, Type};
|
||||
|
||||
const FORMATTED_TYPE_START: &str = "static TY_AFTER_HERE:";
|
||||
const FORMATTED_TYPE_END: &str = "= todo!();";
|
||||
|
||||
/// Attempts to convert the given literal to a string.
|
||||
/// Converts ints and floats to their base 10 counterparts.
|
||||
///
|
||||
/// Returns `None` if the literal is [`Lit::Verbatim`] or if the literal is [`Lit::ByteStr`]
|
||||
/// and the byte string could not be converted to UTF-8.
|
||||
pub fn lit_to_string(lit: Lit) -> Option<String> {
|
||||
match lit {
|
||||
Lit::Str(l) => Some(l.value()),
|
||||
Lit::ByteStr(l) => String::from_utf8(l.value()).ok(),
|
||||
Lit::Byte(l) => Some(String::from(l.value() as char)),
|
||||
Lit::Char(l) => Some(l.value().to_string()),
|
||||
Lit::Int(l) => Some(l.base10_digits().to_string()),
|
||||
Lit::Float(l) => Some(l.base10_digits().to_string()),
|
||||
Lit::Bool(l) => Some(l.value().to_string()),
|
||||
Lit::Verbatim(_) => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_type_string(ty: &Type) -> String {
|
||||
let ty_unformatted = ty.into_token_stream().to_string();
|
||||
let ty_unformatted = ty_unformatted.trim();
|
||||
|
||||
// This should always be valid syntax.
|
||||
// Not Rust code, but syntax, which is the only thing that `syn` cares about.
|
||||
let Ok(file_unformatted) = syn::parse_file(&format!(
|
||||
"{FORMATTED_TYPE_START}{ty_unformatted}{FORMATTED_TYPE_END}"
|
||||
)) else {
|
||||
return ty_unformatted.to_string();
|
||||
};
|
||||
|
||||
let file_formatted = prettyplease::unparse(&file_unformatted);
|
||||
|
||||
let file_trimmed = file_formatted.trim();
|
||||
let start_removed = file_trimmed.trim_start_matches(FORMATTED_TYPE_START);
|
||||
let end_removed = start_removed.trim_end_matches(FORMATTED_TYPE_END);
|
||||
let ty_formatted = end_removed.trim();
|
||||
|
||||
ty_formatted.to_string()
|
||||
}
|
||||
|
||||
/// Represents the `#[deprecated]` attribute.
|
||||
///
|
||||
/// You can use the [`DeprecatedAttribute::from_meta`] function to try to parse an attribute to this struct.
|
||||
#[derive(Default)]
|
||||
pub struct DeprecatedAttribute {
|
||||
pub since: Option<String>,
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
impl DeprecatedAttribute {
|
||||
/// Returns `None` if the given attribute was not a valid form of the `#[deprecated]` attribute.
|
||||
pub fn from_meta(meta: &Meta) -> syn::Result<Self> {
|
||||
if meta.path() != &parse_quote!(deprecated) {
|
||||
return Err(syn::Error::new(
|
||||
meta.span(),
|
||||
"attribute path is not `deprecated`",
|
||||
));
|
||||
}
|
||||
|
||||
match &meta {
|
||||
Meta::Path(_) => Ok(Self::default()),
|
||||
Meta::NameValue(name_value) => {
|
||||
let Expr::Lit(expr_lit) = &name_value.value else {
|
||||
return Err(syn::Error::new(
|
||||
name_value.span(),
|
||||
"literal in `deprecated` value must be a string",
|
||||
));
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
since: None,
|
||||
note: lit_to_string(expr_lit.lit.clone()).map(|s| s.trim().to_string()),
|
||||
})
|
||||
}
|
||||
Meta::List(list) => {
|
||||
let parsed = list.parse_args::<DeprecatedAttributeArgsParser>()?;
|
||||
|
||||
Ok(Self {
|
||||
since: parsed.since.map(|s| s.trim().to_string()),
|
||||
note: parsed.note.map(|s| s.trim().to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod kw {
|
||||
use syn::custom_keyword;
|
||||
custom_keyword!(since);
|
||||
custom_keyword!(note);
|
||||
}
|
||||
|
||||
struct DeprecatedAttributeArgsParser {
|
||||
since: Option<String>,
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
impl Parse for DeprecatedAttributeArgsParser {
|
||||
fn parse(input: ParseStream) -> syn::Result<Self> {
|
||||
let mut since: Option<String> = None;
|
||||
let mut note: Option<String> = None;
|
||||
|
||||
if input.peek(kw::since) {
|
||||
input.parse::<kw::since>()?;
|
||||
input.parse::<Token![=]>()?;
|
||||
|
||||
since = lit_to_string(input.parse()?);
|
||||
}
|
||||
|
||||
if input.peek(Token![,]) && input.peek2(kw::note) {
|
||||
input.parse::<Token![,]>()?;
|
||||
input.parse::<kw::note>()?;
|
||||
input.parse::<Token![=]>()?;
|
||||
|
||||
note = lit_to_string(input.parse()?);
|
||||
}
|
||||
|
||||
Ok(Self { since, note })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user