use crate::{
common::timestamp,
limits::VALIDATION_LIMITS,
models::tag::{sanitize_tag_label, validate_tag_label},
traits::{HasIdPath, HashId, Validatable},
PubkyAppPostKind, APP_PATH, PUBLIC_PATH,
};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
#[cfg(target_arch = "wasm32")]
use crate::traits::Json;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::prelude::*;
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub enum PubkyAppFeedReach {
Following,
Followers,
Friends,
All,
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub enum PubkyAppFeedLayout {
Columns,
Wide,
Visual,
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub enum PubkyAppFeedSort {
Recent,
Popularity,
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct PubkyAppFeedConfig {
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub tags: Option<Vec<String>>,
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub reach: PubkyAppFeedReach,
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub layout: PubkyAppFeedLayout,
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub sort: PubkyAppFeedSort,
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub content: Option<PubkyAppPostKind>,
}
#[cfg(target_arch = "wasm32")]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
impl PubkyAppFeedConfig {
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = fromJson))]
pub fn from_json(js_value: &JsValue) -> Result<Self, String> {
Self::import_json(js_value)
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = toJson))]
pub fn to_json(&self) -> Result<JsValue, String> {
self.export_json()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn tags(&self) -> Option<Vec<String>> {
self.tags.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn reach(&self) -> PubkyAppFeedReach {
self.reach.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn layout(&self) -> PubkyAppFeedLayout {
self.layout.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn sort(&self) -> PubkyAppFeedSort {
self.sort.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn content(&self) -> Option<PubkyAppPostKind> {
self.content.clone()
}
}
impl Validatable for PubkyAppFeedConfig {
fn sanitize(self) -> Self {
let tags = self.tags.map(|tags| {
tags.into_iter()
.map(|tag| sanitize_tag_label(&tag))
.filter(|tag| !tag.is_empty())
.collect()
});
PubkyAppFeedConfig { tags, ..self }
}
fn validate(&self, _id: Option<&str>) -> Result<(), String> {
if let Some(tags) = &self.tags {
if tags.len() > VALIDATION_LIMITS.feed_tags_max_count {
return Err(format!(
"Validation Error: Feed config cannot have more than {} tags",
VALIDATION_LIMITS.feed_tags_max_count
));
}
for tag in tags {
validate_tag_label(tag)?;
}
}
Ok(())
}
}
#[cfg(target_arch = "wasm32")]
impl Json for PubkyAppFeedConfig {}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct PubkyAppFeed {
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub feed: PubkyAppFeedConfig,
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub name: String,
pub created_at: i64,
}
impl PubkyAppFeed {
pub fn new(
tags: Option<Vec<String>>,
reach: PubkyAppFeedReach,
layout: PubkyAppFeedLayout,
sort: PubkyAppFeedSort,
content: Option<PubkyAppPostKind>,
name: String,
) -> Self {
let created_at = timestamp();
let feed = PubkyAppFeedConfig {
tags,
reach,
layout,
sort,
content,
};
Self {
feed,
name,
created_at,
}
.sanitize()
}
}
#[cfg(target_arch = "wasm32")]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
impl PubkyAppFeed {
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = fromJson))]
pub fn from_json(js_value: &JsValue) -> Result<Self, String> {
Self::import_json(js_value)
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = toJson))]
pub fn to_json(&self) -> Result<JsValue, String> {
self.export_json()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn feed(&self) -> PubkyAppFeedConfig {
self.feed.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn name(&self) -> String {
self.name.clone()
}
}
#[cfg(target_arch = "wasm32")]
impl Json for PubkyAppFeed {}
impl HashId for PubkyAppFeed {
fn get_id_data(&self) -> String {
serde_json::to_string(&self.feed).unwrap_or_default()
}
}
impl HasIdPath for PubkyAppFeed {
const PATH_SEGMENT: &'static str = "feeds/";
fn create_path(id: &str) -> String {
[PUBLIC_PATH, APP_PATH, Self::PATH_SEGMENT, id].concat()
}
}
impl Validatable for PubkyAppFeed {
fn validate(&self, id: Option<&str>) -> Result<(), String> {
if let Some(id) = id {
self.validate_id(id)?;
}
if self.name.trim().is_empty() {
return Err("Validation Error: Feed name cannot be empty".into());
}
self.feed.validate(None)?;
Ok(())
}
fn sanitize(self) -> Self {
let name = self.name.trim().to_string();
let feed = self.feed.sanitize();
PubkyAppFeed {
feed,
name,
created_at: self.created_at,
}
}
}
impl FromStr for PubkyAppFeedReach {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"following" => Ok(PubkyAppFeedReach::Following),
"followers" => Ok(PubkyAppFeedReach::Followers),
"friends" => Ok(PubkyAppFeedReach::Friends),
"all" => Ok(PubkyAppFeedReach::All),
_ => Err(format!("Invalid feed reach: {}", s)),
}
}
}
impl FromStr for PubkyAppFeedLayout {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"columns" => Ok(PubkyAppFeedLayout::Columns),
"wide" => Ok(PubkyAppFeedLayout::Wide),
"visual" => Ok(PubkyAppFeedLayout::Visual),
_ => Err(format!("Invalid feed layout: {}", s)),
}
}
}
impl FromStr for PubkyAppFeedSort {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"recent" => Ok(PubkyAppFeedSort::Recent),
"popularity" => Ok(PubkyAppFeedSort::Popularity),
_ => Err(format!("Invalid feed sort: {}", s)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{limits::VALIDATION_LIMITS, traits::Validatable};
#[test]
fn test_new() {
let feed = PubkyAppFeed::new(
Some(vec!["bitcoin".to_string(), "rust".to_string()]),
PubkyAppFeedReach::Following,
PubkyAppFeedLayout::Columns,
PubkyAppFeedSort::Recent,
Some(PubkyAppPostKind::Image),
"Rust Bitcoiners".to_string(),
);
let feed_config = PubkyAppFeedConfig {
tags: Some(vec!["bitcoin".to_string(), "rust".to_string()]),
reach: PubkyAppFeedReach::Following,
layout: PubkyAppFeedLayout::Columns,
sort: PubkyAppFeedSort::Recent,
content: Some(PubkyAppPostKind::Image),
};
assert_eq!(feed.feed, feed_config);
assert_eq!(feed.name, "Rust Bitcoiners");
let now = timestamp();
assert!(feed.created_at <= now && feed.created_at >= now - 1_000_000);
}
#[test]
fn test_create_id() {
let feed = PubkyAppFeed::new(
Some(vec!["bitcoin".to_string(), "rust".to_string()]),
PubkyAppFeedReach::Following,
PubkyAppFeedLayout::Columns,
PubkyAppFeedSort::Recent,
None,
"Rust Bitcoiners".to_string(),
);
let feed_id = feed.create_id();
println!("Feed ID: {}", feed_id);
assert!(!feed_id.is_empty());
}
#[test]
fn test_validate() {
let feed = PubkyAppFeed::new(
Some(vec!["bitcoin".to_string(), "rust".to_string()]),
PubkyAppFeedReach::Following,
PubkyAppFeedLayout::Columns,
PubkyAppFeedSort::Recent,
None,
"Rust Bitcoiners".to_string(),
);
let feed_id = feed.create_id();
let result = feed.validate(Some(&feed_id));
assert!(result.is_ok());
}
#[test]
fn test_validate_invalid_id() {
let feed = PubkyAppFeed::new(
Some(vec!["bitcoin".to_string(), "rust".to_string()]),
PubkyAppFeedReach::Following,
PubkyAppFeedLayout::Columns,
PubkyAppFeedSort::Recent,
None,
"Rust Bitcoiners".to_string(),
);
let invalid_id = "INVALIDID";
let result = feed.validate(Some(invalid_id));
assert!(result.is_err());
}
#[test]
fn test_sanitize() {
let feed = PubkyAppFeed::new(
Some(vec![" BiTcoin ".to_string(), " RUST ".to_string()]),
PubkyAppFeedReach::Following,
PubkyAppFeedLayout::Columns,
PubkyAppFeedSort::Recent,
None,
" Rust Bitcoiners".to_string(),
);
assert_eq!(feed.name, "Rust Bitcoiners");
assert_eq!(
feed.feed.tags,
Some(vec!["bitcoin".to_string(), "rust".to_string()])
);
}
#[test]
fn test_try_from_valid() {
let feed_json = r#"
{
"feed": {
"tags": ["bitcoin", "rust"],
"reach": "following",
"layout": "columns",
"sort": "recent",
"content": "video"
},
"name": "My Feed",
"created_at": 1700000000
}
"#;
let feed: PubkyAppFeed = serde_json::from_str(feed_json).unwrap();
let feed_id = feed.create_id();
let blob = feed_json.as_bytes();
let feed_parsed = <PubkyAppFeed as Validatable>::try_from(blob, &feed_id).unwrap();
assert_eq!(feed_parsed.name, "My Feed");
assert_eq!(
feed_parsed.feed.tags,
Some(vec!["bitcoin".to_string(), "rust".to_string()])
);
}
#[test]
fn test_validate_too_many_tags() {
let feed = PubkyAppFeed::new(
Some(vec![
"tag1".to_string(),
"tag2".to_string(),
"tag3".to_string(),
"tag4".to_string(),
"tag5".to_string(),
"tag6".to_string(), ]),
PubkyAppFeedReach::Following,
PubkyAppFeedLayout::Columns,
PubkyAppFeedSort::Recent,
None,
"Test Feed".to_string(),
);
let feed_id = feed.create_id();
let result = feed.validate(Some(&feed_id));
assert!(result.is_err());
assert!(result.unwrap_err().contains(&format!(
"more than {} tags",
VALIDATION_LIMITS.feed_tags_max_count
)));
}
#[test]
fn test_validate_tag_too_long() {
let feed = PubkyAppFeed::new(
Some(vec!["a".repeat(VALIDATION_LIMITS.tag_label_max_length + 1)]),
PubkyAppFeedReach::Following,
PubkyAppFeedLayout::Columns,
PubkyAppFeedSort::Recent,
None,
"Test Feed".to_string(),
);
let feed_id = feed.create_id();
let result = feed.validate(Some(&feed_id));
assert!(result.is_err());
assert!(result.unwrap_err().contains("exceeds maximum length"));
}
#[test]
fn test_validate_tag_with_whitespace() {
let feed = PubkyAppFeed::new(
Some(vec!["bit coin".to_string()]),
PubkyAppFeedReach::Following,
PubkyAppFeedLayout::Columns,
PubkyAppFeedSort::Recent,
None,
"Test Feed".to_string(),
);
let feed_id = feed.create_id();
let result = feed.validate(Some(&feed_id));
assert!(result.is_err());
assert!(result.unwrap_err().contains("whitespace"));
}
#[test]
fn test_validate_tag_with_invalid_char() {
let feed = PubkyAppFeed::new(
Some(vec!["bitcoin,rust".to_string()]),
PubkyAppFeedReach::Following,
PubkyAppFeedLayout::Columns,
PubkyAppFeedSort::Recent,
None,
"Test Feed".to_string(),
);
let feed_id = feed.create_id();
let result = feed.validate(Some(&feed_id));
assert!(result.is_err());
assert!(result.unwrap_err().contains("invalid character"));
}
#[test]
fn test_validate_max_tags() {
let feed = PubkyAppFeed::new(
Some(vec![
"tag1".to_string(),
"tag2".to_string(),
"tag3".to_string(),
"tag4".to_string(),
"tag5".to_string(),
]),
PubkyAppFeedReach::Following,
PubkyAppFeedLayout::Columns,
PubkyAppFeedSort::Recent,
None,
"Test Feed".to_string(),
);
let feed_id = feed.create_id();
let result = feed.validate(Some(&feed_id));
assert!(result.is_ok());
}
#[test]
fn test_sanitize_filters_empty_tags() {
let feed = PubkyAppFeed::new(
Some(vec![
"bitcoin".to_string(),
" ".to_string(), "rust".to_string(),
]),
PubkyAppFeedReach::Following,
PubkyAppFeedLayout::Columns,
PubkyAppFeedSort::Recent,
None,
"Test Feed".to_string(),
);
assert_eq!(
feed.feed.tags,
Some(vec!["bitcoin".to_string(), "rust".to_string()])
);
}
#[test]
fn test_validate_tag_errors() {
let invalid_cases = vec![
(
"a".repeat(VALIDATION_LIMITS.tag_label_max_length + 1),
"exceeds maximum length",
),
("bit coin".to_string(), "whitespace"),
("bitcoin,rust".to_string(), "invalid character"),
];
for (invalid_tag, expected_error) in invalid_cases {
let feed = PubkyAppFeed::new(
Some(vec![invalid_tag.clone()]),
PubkyAppFeedReach::Following,
PubkyAppFeedLayout::Columns,
PubkyAppFeedSort::Recent,
None,
"Test Feed".to_string(),
);
let feed_id = feed.create_id();
let result = feed.validate(Some(&feed_id));
assert!(result.is_err(), "Should reject tag: {}", invalid_tag);
assert!(
result.unwrap_err().contains(expected_error),
"Expected error containing '{}' for tag: {}",
expected_error,
invalid_tag
);
}
}
#[test]
fn test_feed_reach_from_str() {
assert_eq!(
"following".parse::<PubkyAppFeedReach>().unwrap(),
PubkyAppFeedReach::Following
);
assert_eq!(
"followers".parse::<PubkyAppFeedReach>().unwrap(),
PubkyAppFeedReach::Followers
);
assert_eq!(
"friends".parse::<PubkyAppFeedReach>().unwrap(),
PubkyAppFeedReach::Friends
);
assert_eq!(
"all".parse::<PubkyAppFeedReach>().unwrap(),
PubkyAppFeedReach::All
);
assert!("invalid".parse::<PubkyAppFeedReach>().is_err());
}
#[test]
fn test_feed_layout_from_str() {
assert_eq!(
"columns".parse::<PubkyAppFeedLayout>().unwrap(),
PubkyAppFeedLayout::Columns
);
assert_eq!(
"wide".parse::<PubkyAppFeedLayout>().unwrap(),
PubkyAppFeedLayout::Wide
);
assert_eq!(
"visual".parse::<PubkyAppFeedLayout>().unwrap(),
PubkyAppFeedLayout::Visual
);
assert!("invalid".parse::<PubkyAppFeedLayout>().is_err());
}
#[test]
fn test_feed_sort_from_str() {
assert_eq!(
"recent".parse::<PubkyAppFeedSort>().unwrap(),
PubkyAppFeedSort::Recent
);
assert_eq!(
"popularity".parse::<PubkyAppFeedSort>().unwrap(),
PubkyAppFeedSort::Popularity
);
assert!("invalid".parse::<PubkyAppFeedSort>().is_err());
}
}