Current section

Files

Jump to
nostr_lib lib nostr event note.ex
Raw

lib/nostr/event/note.ex

defmodule Nostr.Event.Note do
@moduledoc """
Text notes and threads (Kind 1)
Implements NIP-01 (basic notes), NIP-10 (threading), and NIP-14 (subject tags).
## NIP-10 Threading
Notes can reference other notes using `e` tags with markers:
- `root` - The root event of the thread
- `reply` - The direct parent event being replied to
For top-level replies (direct reply to root), only the `root` marker is used.
For nested replies, both `root` and `reply` markers are used.
## NIP-14 Subject Tags
Notes can have a subject tag for email-like threading display:
Note.create("Hello!", subject: "Introduction")
When replying, the subject is automatically replicated with "Re:" prefix.
## Quoting Events
Notes can quote other events using `q` tags. Quoted events should be
referenced in the content with NIP-21 URIs (e.g., `nostr:nevent1...`).
## Examples
# Create a simple note
Note.create("Hello world!")
# Create a note with a subject
Note.create("Let's discuss this", subject: "Meeting Notes")
# Create a reply to a note
Note.reply("I agree!", parent_note)
# Create a note with a quote
Note.quote("Check this out: nostr:nevent1...", %{id: "abc123"})
See:
- https://github.com/nostr-protocol/nips/blob/master/01.md
- https://github.com/nostr-protocol/nips/blob/master/10.md
- https://github.com/nostr-protocol/nips/blob/master/14.md
- https://github.com/nostr-protocol/nips/blob/master/30.md
- https://github.com/nostr-protocol/nips/blob/master/36.md
"""
@moduledoc tags: [:event, :nip01, :nip10, :nip14, :nip30, :nip36], nip: [1, 10, 14, 30, 36]
alias Nostr.Tag
@typedoc "Event reference with optional relay and author hints"
@type event_ref() :: %{
id: binary(),
relay: binary() | nil,
pubkey: binary() | nil,
marker: :root | :reply | nil
}
@typedoc "Quote reference (q tag)"
@type quote_ref() :: %{
id: binary(),
relay: binary() | nil,
pubkey: binary() | nil
}
defstruct [
:event,
:note,
:author,
# NIP-10 thread structure
:root,
:reply_to,
:mentions,
:quotes,
:participants,
# NIP-14 subject tag
:subject,
# NIP-30 custom emoji
:emojis,
# NIP-36 content warning
:content_warning,
# Legacy format indicator
:is_legacy_format
]
@type t() :: %__MODULE__{
event: Nostr.Event.t(),
note: String.t(),
author: binary(),
root: event_ref() | nil,
reply_to: event_ref() | nil,
mentions: [event_ref()],
quotes: [quote_ref()],
participants: [binary()],
subject: String.t() | nil,
emojis: %{binary() => binary()},
content_warning: Nostr.NIP36.warning(),
is_legacy_format: boolean()
}
@doc """
Parses a kind 1 event into a Note struct.
Extracts thread structure from e tags (with NIP-10 markers if present),
quote references from q tags, and participants from p tags.
Events with marked e tags (root/reply) are parsed using the preferred NIP-10 format.
Events without markers fall back to deprecated positional parsing.
"""
@spec parse(event :: Nostr.Event.t()) :: t()
def parse(%Nostr.Event{kind: 1} = event) do
e_refs = parse_e_tags(event.tags)
has_markers = Enum.any?(e_refs, &(&1.marker != nil))
{root, reply_to, mentions, is_legacy} =
if has_markers do
resolve_thread_marked(e_refs)
else
resolve_thread_positional(e_refs)
end
%__MODULE__{
event: event,
note: event.content,
author: event.pubkey,
root: root,
reply_to: reply_to,
mentions: mentions,
quotes: parse_q_tags(event.tags),
participants: parse_p_tags(event.tags),
subject: parse_subject_tag(event.tags),
emojis: Nostr.NIP30.from_tags(event.tags),
content_warning: Nostr.NIP36.from_tags(event.tags),
is_legacy_format: is_legacy
}
end
@doc """
Create a new Note event.
## Options
- `:pubkey` - author pubkey
- `:created_at` - timestamp
- `:tags` - additional tags
- `:root` - thread root event reference (map with :id, optional :relay, :pubkey)
- `:reply_to` - direct parent event reference
- `:quotes` - list of quoted event references
- `:participants` - list of pubkeys to notify
- `:subject` - NIP-14 subject tag for email-like threading (should be < 80 chars)
- `:emojis` - NIP-30 custom emoji map `%{"shortcode" => "url"}`
- `:content_warning` - NIP-36 content warning (string reason or `true` for no reason)
## Examples
Note.create("Hello world!")
Note.create("Let's discuss this", subject: "Meeting Notes")
Note.create("This is a reply!", root: %{id: "abc123", relay: "wss://relay.example.com"})
Note.create("Hello :wave:!", emojis: %{"wave" => "https://example.com/wave.png"})
Note.create("Sensitive content", content_warning: "Spoilers")
"""
@spec create(note :: String.t(), opts :: Keyword.t()) :: t()
def create(note, opts \\ []) do
thread_tags = build_thread_tags(opts)
existing_tags = Keyword.get(opts, :tags, [])
all_tags = thread_tags ++ existing_tags
opts =
opts
|> Keyword.put(:content, note)
|> Keyword.put(:tags, all_tags)
1
|> Nostr.Event.create(opts)
|> parse()
end
@doc """
Create a reply to an existing note.
For top-level replies (direct reply to root), only the root marker is used.
For nested replies, both root and reply markers are used.
Automatically collects participants from the parent note's author and participants.
## Examples
# Reply to a root note
Note.reply("I agree!", root_note)
# Reply to a nested reply (preserves thread root)
Note.reply("Me too!", nested_reply)
"""
@spec reply(content :: String.t(), parent :: t() | map(), opts :: Keyword.t()) :: t()
def reply(content, parent, opts \\ [])
def reply(content, %__MODULE__{} = parent, opts) do
root_ref = parent.root || to_ref(parent)
reply_ref = if parent.root, do: to_ref(parent), else: nil
participants =
[parent.author | parent.participants || []]
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
# NIP-14: replicate subject with "Re:" prefix if not already present
subject = derive_reply_subject(Keyword.get(opts, :subject), parent.subject)
opts =
opts
|> Keyword.merge(
root: root_ref,
reply_to: reply_ref,
participants: participants
)
|> maybe_put_subject(subject)
create(content, opts)
end
def reply(content, %{id: _id} = parent_ref, opts) do
opts = Keyword.merge(opts, root: parent_ref)
create(content, opts)
end
@doc """
Create a note that quotes another event.
The quoted event should be referenced in the content with a NIP-21 URI.
## Examples
Note.quote(
"Check this out: nostr:nevent1...",
%{id: "abc123", relay: "wss://relay.example.com", pubkey: "def456"}
)
Note.quote("Multiple quotes!", [%{id: "abc"}, %{id: "def"}])
"""
@spec quote_event(
content :: String.t(),
quoted :: quote_ref() | [quote_ref()],
opts :: Keyword.t()
) :: t()
def quote_event(content, quoted, opts \\ []) do
quotes = List.wrap(quoted)
opts = Keyword.put(opts, :quotes, quotes)
create(content, opts)
end
# Utility functions
@doc "Check if this note is a reply (has root reference)"
@spec reply?(t()) :: boolean()
def reply?(%__MODULE__{root: root}), do: root != nil
@doc "Check if this note is a top-level reply (direct reply to root, no intermediate parent)"
@spec top_level_reply?(t()) :: boolean()
def top_level_reply?(%__MODULE__{root: root, reply_to: nil}) when root != nil, do: true
def top_level_reply?(_note), do: false
@doc "Check if this note quotes other events"
@spec has_quotes?(t()) :: boolean()
def has_quotes?(%__MODULE__{quotes: [_first | _rest]}), do: true
def has_quotes?(_note), do: false
@doc "Get the thread root event ID"
@spec thread_root_id(t()) :: binary() | nil
def thread_root_id(%__MODULE__{root: %{id: id}}), do: id
def thread_root_id(_note), do: nil
@doc "Get the direct parent event ID (reply_to if present, otherwise root)"
@spec parent_id(t()) :: binary() | nil
def parent_id(%__MODULE__{reply_to: %{id: id}}), do: id
def parent_id(%__MODULE__{root: %{id: id}}), do: id
def parent_id(_note), do: nil
# E-tag parsing
defp parse_e_tags(tags) do
tags
|> Enum.filter(&(&1.type == :e))
|> Enum.map(&parse_e_tag/1)
end
defp parse_e_tag(%Tag{data: id, info: info}) do
%{
id: id,
relay: get_relay(info),
pubkey: get_pubkey(info),
marker: get_marker(info)
}
end
defp get_relay([relay | _rest]) when is_binary(relay) and byte_size(relay) > 0, do: relay
defp get_relay(_info), do: nil
defp get_marker([_relay, "root" | _rest]), do: :root
defp get_marker([_relay, "reply" | _rest]), do: :reply
defp get_marker(_info), do: nil
defp get_pubkey([_relay, _marker, pubkey | _rest])
when is_binary(pubkey) and byte_size(pubkey) > 0, do: pubkey
defp get_pubkey(_info), do: nil
# Thread resolution - marked tags (preferred NIP-10 format)
defp resolve_thread_marked(refs) do
root = Enum.find(refs, &(&1.marker == :root))
reply = Enum.find(refs, &(&1.marker == :reply))
mentions = Enum.filter(refs, &(&1.marker == nil))
{root, reply, mentions, false}
end
# Thread resolution - positional tags (deprecated NIP-10 format)
defp resolve_thread_positional([]), do: {nil, nil, [], true}
defp resolve_thread_positional([only]), do: {only, nil, [], true}
defp resolve_thread_positional([root, reply]), do: {root, reply, [], true}
defp resolve_thread_positional([root | rest]) do
{mentions, [reply]} = Enum.split(rest, -1)
{root, reply, mentions, true}
end
# Q-tag parsing
defp parse_q_tags(tags) do
tags
|> Enum.filter(&(&1.type == :q))
|> Enum.map(&parse_q_tag/1)
end
defp parse_q_tag(%Tag{data: id, info: info}) do
%{
id: id,
relay: info |> Enum.at(0) |> empty_to_nil(),
pubkey: info |> Enum.at(1) |> empty_to_nil()
}
end
# P-tag parsing
defp parse_p_tags(tags) do
tags
|> Enum.filter(&(&1.type == :p))
|> Enum.map(& &1.data)
end
# Subject tag parsing (NIP-14)
defp parse_subject_tag(tags) do
case Enum.find(tags, &(&1.type == :subject)) do
%Tag{data: subject} -> subject
nil -> nil
end
end
# Tag building
defp build_thread_tags(opts) do
root_tags = build_root_tag(Keyword.get(opts, :root))
reply_tags = build_reply_tag(Keyword.get(opts, :reply_to))
p_tags = build_p_tags(Keyword.get(opts, :participants, []))
q_tags = build_q_tags(Keyword.get(opts, :quotes, []))
subject_tags = build_subject_tag(Keyword.get(opts, :subject))
emoji_tags = build_emoji_tags(Keyword.get(opts, :emojis))
cw_tags = build_content_warning_tag(Keyword.get(opts, :content_warning))
root_tags ++ reply_tags ++ p_tags ++ q_tags ++ subject_tags ++ emoji_tags ++ cw_tags
end
defp build_root_tag(nil), do: []
defp build_root_tag(%{id: id} = ref) do
info = build_e_tag_info(ref, "root")
[Tag.create(:e, id, info)]
end
defp build_reply_tag(nil), do: []
defp build_reply_tag(%{id: id} = ref) do
info = build_e_tag_info(ref, "reply")
[Tag.create(:e, id, info)]
end
defp build_e_tag_info(ref, marker) do
relay = Map.get(ref, :relay) || ""
pubkey = Map.get(ref, :pubkey)
if pubkey do
[relay, marker, pubkey]
else
[relay, marker]
end
end
defp build_p_tags(pubkeys) do
pubkeys
|> Enum.reject(&is_nil/1)
|> Enum.map(&Tag.create(:p, &1))
end
defp build_q_tags(quotes) do
Enum.map(quotes, fn
%{id: id, relay: relay, pubkey: pubkey} when is_binary(relay) and is_binary(pubkey) ->
Tag.create(:q, id, [relay, pubkey])
%{id: id, relay: relay} when is_binary(relay) ->
Tag.create(:q, id, [relay])
%{id: id} ->
Tag.create(:q, id)
id when is_binary(id) ->
Tag.create(:q, id)
end)
end
defp build_subject_tag(nil), do: []
defp build_subject_tag(subject) when is_binary(subject), do: [Tag.create(:subject, subject)]
defp build_content_warning_tag(nil), do: []
defp build_content_warning_tag(value), do: [Nostr.NIP36.to_tag(value)]
defp build_emoji_tags(nil), do: []
defp build_emoji_tags(emojis), do: Nostr.NIP30.build_tags(emojis)
# NIP-14 subject handling
defp derive_reply_subject(explicit, _parent_subject) when is_binary(explicit), do: explicit
defp derive_reply_subject(_explicit, nil), do: nil
defp derive_reply_subject(_explicit, parent_subject) when is_binary(parent_subject) do
if String.starts_with?(parent_subject, "Re:") do
parent_subject
else
"Re: " <> parent_subject
end
end
defp maybe_put_subject(opts, nil), do: opts
defp maybe_put_subject(opts, subject), do: Keyword.put(opts, :subject, subject)
# Helpers
defp to_ref(%__MODULE__{event: event}) do
%{
id: event.id,
relay: nil,
pubkey: event.pubkey
}
end
defp empty_to_nil(""), do: nil
defp empty_to_nil(nil), do: nil
defp empty_to_nil(value), do: value
end