defmodule Nostr.Event.Article do @moduledoc """ Long-form content (Kind 30023 for published, Kind 30024 for drafts). Implements NIP-23: https://github.com/nostr-protocol/nips/blob/master/23.md Articles are addressable events containing Markdown content with optional metadata like title, summary, image, and publication date. Also supports NIP-36 content warnings. ## Examples # Create a published article Article.create("# My Article\\n\\nContent here...", "my-article", title: "My Article", summary: "A brief description", image: "https://example.com/image.jpg", hashtags: ["nostr", "tutorial"] ) # Create a draft Article.create_draft("Work in progress...", "draft-article", title: "Draft Title" ) # Publish a draft Article.publish(draft_article) See: - https://github.com/nostr-protocol/nips/blob/master/23.md - https://github.com/nostr-protocol/nips/blob/master/36.md """ @moduledoc tags: [:event, :nip23, :nip36], nip: [23, 36] alias Nostr.Event alias Nostr.Tag @kind_published 30_023 @kind_draft 30_024 @typedoc "Event reference from e tag" @type event_ref() :: %{ id: binary(), relay: binary() | nil } @typedoc "Addressable event reference from a tag" @type addr_ref() :: %{ coordinates: binary(), relay: binary() | nil } @type t() :: %__MODULE__{ event: Event.t(), identifier: binary(), title: binary() | nil, image: binary() | nil, summary: binary() | nil, published_at: DateTime.t() | nil, content: binary(), hashtags: [binary()], event_refs: [event_ref()], addr_refs: [addr_ref()], content_warning: Nostr.NIP36.warning(), draft?: boolean() } defstruct [ :event, :identifier, :title, :image, :summary, :published_at, :content, :content_warning, hashtags: [], event_refs: [], addr_refs: [], draft?: false ] @doc """ Parses a kind 30023 or 30024 event into an Article struct. """ @spec parse(Event.t()) :: t() | {:error, String.t(), Event.t()} def parse(%Event{kind: kind} = event) when kind in [@kind_published, @kind_draft] do %__MODULE__{ event: event, identifier: get_identifier(event), title: get_tag_value(event, "title"), image: get_tag_value(event, "image"), summary: get_tag_value(event, "summary"), published_at: get_published_at(event), content: event.content, hashtags: get_hashtags(event), event_refs: get_event_refs(event), addr_refs: get_addr_refs(event), content_warning: Nostr.NIP36.from_tags(event.tags), draft?: kind == @kind_draft } end def parse(%Event{} = event) do {:error, "Event is not an article (expected kind 30023 or 30024)", event} end @doc """ Creates a published article (kind 30023). ## Options - `:title` - Article title - `:image` - URL to header image - `:summary` - Brief description - `:published_at` - DateTime of first publication (defaults to now) - `:hashtags` - List of topic hashtags - `:event_refs` - List of referenced event IDs or `{id, relay}` tuples - `:addr_refs` - List of referenced addressable event coordinates or `{coord, relay}` tuples - `:content_warning` - NIP-36 content warning (string reason or `true` for no reason) ## Examples Article.create("# Hello\\n\\nWorld", "hello-world", title: "Hello") Article.create("Sensitive content", "nsfw-article", content_warning: "NSFW") """ @spec create(binary(), binary(), keyword()) :: t() def create(content, identifier, opts \\ []) do opts = Keyword.put(opts, :draft, false) do_create(content, identifier, opts) end @doc """ Creates a draft article (kind 30024). Takes the same options as `create/3`. """ @spec create_draft(binary(), binary(), keyword()) :: t() def create_draft(content, identifier, opts \\ []) do opts = Keyword.put(opts, :draft, true) do_create(content, identifier, opts) end @doc """ Converts a draft article to a published article (kind 30024 -> 30023). Sets `published_at` to the current time if not already set. """ @spec publish(t()) :: t() def publish(%__MODULE__{draft?: true} = article) do published_at = article.published_at || DateTime.utc_now() tags = build_tags( article.identifier, title: article.title, image: article.image, summary: article.summary, published_at: published_at, hashtags: article.hashtags, event_refs: Enum.map(article.event_refs, &event_ref_to_tuple/1), addr_refs: Enum.map(article.addr_refs, &addr_ref_to_tuple/1), content_warning: article.content_warning ) event = @kind_published |> Event.create(tags: tags, content: article.content) |> parse() %{event | published_at: published_at} end def publish(%__MODULE__{draft?: false} = article), do: article @doc """ Returns true if this is a draft article. """ @spec draft?(t()) :: boolean() def draft?(%__MODULE__{draft?: draft}), do: draft @doc """ Returns the article's address coordinates for use in `a` tags. Format: `30023::` or `30024::` """ @spec coordinates(t()) :: binary() | nil def coordinates(%__MODULE__{event: %Event{pubkey: nil}}), do: nil def coordinates(%__MODULE__{event: event, identifier: identifier, draft?: draft?}) do kind = if draft?, do: @kind_draft, else: @kind_published "#{kind}:#{event.pubkey}:#{identifier}" end # Private functions defp do_create(content, identifier, opts) do draft? = Keyword.get(opts, :draft, false) kind = if draft?, do: @kind_draft, else: @kind_published tags = build_tags(identifier, opts) kind |> Event.create(tags: tags, content: content) |> parse() end defp build_tags(identifier, opts) do title = Keyword.get(opts, :title) image = Keyword.get(opts, :image) summary = Keyword.get(opts, :summary) published_at = Keyword.get(opts, :published_at) hashtags = Keyword.get(opts, :hashtags, []) event_refs = Keyword.get(opts, :event_refs, []) addr_refs = Keyword.get(opts, :addr_refs, []) content_warning = Keyword.get(opts, :content_warning) [Tag.create(:d, identifier)] ++ maybe_tag("title", title) ++ maybe_tag("image", image) ++ maybe_tag("summary", summary) ++ maybe_published_at_tag(published_at) ++ maybe_content_warning_tag(content_warning) ++ Enum.map(hashtags, &Tag.create(:t, &1)) ++ Enum.map(event_refs, &build_event_ref_tag/1) ++ Enum.map(addr_refs, &build_addr_ref_tag/1) end defp maybe_tag(_type, nil), do: [] defp maybe_tag(type, value), do: [Tag.create(type, value)] defp maybe_published_at_tag(nil), do: [] defp maybe_published_at_tag(%DateTime{} = dt) do [Tag.create("published_at", Integer.to_string(DateTime.to_unix(dt)))] end defp maybe_content_warning_tag(nil), do: [] defp maybe_content_warning_tag(value), do: [Nostr.NIP36.to_tag(value)] defp build_event_ref_tag({id, relay}) when is_binary(relay), do: Tag.create(:e, id, [relay]) defp build_event_ref_tag(id) when is_binary(id), do: Tag.create(:e, id) defp build_addr_ref_tag({coord, relay}) when is_binary(relay), do: Tag.create(:a, coord, [relay]) defp build_addr_ref_tag(coord) when is_binary(coord), do: Tag.create(:a, coord) defp event_ref_to_tuple(%{id: id, relay: nil}), do: id defp event_ref_to_tuple(%{id: id, relay: relay}), do: {id, relay} defp addr_ref_to_tuple(%{coordinates: coord, relay: nil}), do: coord defp addr_ref_to_tuple(%{coordinates: coord, relay: relay}), do: {coord, relay} defp get_identifier(%Event{tags: tags}) do case Enum.find(tags, &(&1.type == :d)) do %Tag{data: id} -> id nil -> "" end end defp get_tag_value(%Event{tags: tags}, type) do case Enum.find(tags, &(to_string(&1.type) == type)) do %Tag{data: value} -> value nil -> nil end end defp get_published_at(%Event{tags: tags}) do case Enum.find(tags, &(to_string(&1.type) == "published_at")) do %Tag{data: timestamp} -> case Integer.parse(timestamp) do {unix, ""} -> DateTime.from_unix!(unix) _parse_fail -> nil end nil -> nil end end defp get_hashtags(%Event{tags: tags}) do tags |> Enum.filter(&(&1.type == :t)) |> Enum.map(& &1.data) end defp get_event_refs(%Event{tags: tags}) do tags |> Enum.filter(&(&1.type == :e)) |> Enum.map(fn %Tag{data: id, info: info} -> %{id: id, relay: List.first(info)} end) end defp get_addr_refs(%Event{tags: tags}) do tags |> Enum.filter(&(&1.type == :a)) |> Enum.map(fn %Tag{data: coord, info: info} -> %{coordinates: coord, relay: List.first(info)} end) end end