Improve Nostr profile/relay settings forms

* Mark input fields as "added" when values were auto-added
* Add suggested values for existing fields (NIP-05/LUD-16)
This commit is contained in:
2026-09-11 16:12:31 -06:00
parent 0ceedf18e5
commit 96f86f3f53
10 changed files with 451 additions and 24 deletions
@@ -12,7 +12,7 @@
</p>
<% end %>
<%= tag.p class: "flex gap-x-1", data: {
<%= tag.div class: "flex gap-x-1", data: {
controller: @resettable ? "settings--resettable-field" : nil,
} do %>
<%= content %>
@@ -24,13 +24,125 @@
<% end %>
<% end %>
<%= render FormElements::FieldsetComponent.new(tag: "div", title: "Nostr address (NIP-05)") do %>
<%= f.text_field :nip05_address, value: @profile["nip05"], class: "w-full sm:w-3/5" %>
<% if @nip05_state[:status] == :added %>
<div class="w-full sm:w-3/5 flex flex-col" data-controller="field-suggestion"
data-field-suggestion-added-value-value="<%= @user.address %>">
<div class="relative">
<%= f.text_field :nip05_address, value: @nip05_state[:value],
data: {
:'field-suggestion-target' => "input",
action: "input->field-suggestion#checkAdded"
},
class: "w-full pr-16 border-emerald-500 ring-1 ring-emerald-500 focus:border-emerald-500 focus:ring-emerald-500 bg-emerald-50/20" %>
<span class="absolute inset-y-0 right-2.5 flex items-center pointer-events-none"
data-field-suggestion-target="badge">
<span class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-semibold text-emerald-800">
added
</span>
</span>
</div>
<div class="mt-1.5 flex items-center justify-between text-xs text-amber-800 bg-amber-50 rounded px-2.5 py-1.5 border border-amber-200 hidden"
data-field-suggestion-target="suggestion">
<span>Account address: <strong><%= @user.address %></strong></span>
<button type="button" class="font-semibold text-blue-600 hover:text-blue-800 underline ml-2"
data-action="click->field-suggestion#apply"
data-field-suggestion-value-param="<%= @user.address %>">
Use this
</button>
</div>
</div>
<% elsif @nip05_state[:status] == :different %>
<div class="w-full sm:w-3/5 flex flex-col" data-controller="field-suggestion"
data-field-suggestion-added-value-value="<%= @nip05_state[:suggested] %>">
<div class="relative">
<%= f.text_field :nip05_address, value: @nip05_state[:value],
data: {
:'field-suggestion-target' => "input",
action: "input->field-suggestion#checkAdded"
},
class: "w-full pr-20 border-amber-400 ring-1 ring-amber-400 focus:border-amber-400 focus:ring-amber-400" %>
<span class="absolute inset-y-0 right-2.5 flex items-center pointer-events-none"
data-field-suggestion-target="badge">
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-800">
different
</span>
</span>
</div>
<div class="mt-1.5 flex items-center justify-between text-xs text-amber-800 bg-amber-50 rounded px-2.5 py-1.5 border border-amber-200"
data-field-suggestion-target="suggestion">
<span>Account address: <strong><%= @nip05_state[:suggested] %></strong></span>
<button type="button" class="font-semibold text-blue-600 hover:text-blue-800 underline ml-2"
data-action="click->field-suggestion#apply"
data-field-suggestion-value-param="<%= @nip05_state[:suggested] %>">
Use this
</button>
</div>
</div>
<% else %>
<%= f.text_field :nip05_address, value: @nip05_state[:value], class: "w-full sm:w-3/5" %>
<% end %>
<% if @validation_errors.present? && @validation_errors[:nip05_address].present? %>
<p class="error-msg mt-2"><%= @validation_errors[:nip05_address].first %></p>
<% end %>
<% end %>
<%= render FormElements::FieldsetComponent.new(tag: "div", title: "Ligtning address for Zaps") do %>
<%= f.text_field :lud16_address, value: @profile["lud16"], class: "w-full sm:w-3/5" %>
<%= render FormElements::FieldsetComponent.new(tag: "div", title: "Lightning address for Zaps") do %>
<% if @lud16_state[:status] == :added %>
<div class="w-full sm:w-3/5 flex flex-col" data-controller="field-suggestion"
data-field-suggestion-added-value-value="<%= @user.address %>">
<div class="relative">
<%= f.text_field :lud16_address, value: @lud16_state[:value],
data: {
:'field-suggestion-target' => "input",
action: "input->field-suggestion#checkAdded"
},
class: "w-full pr-16 border-emerald-500 ring-1 ring-emerald-500 focus:border-emerald-500 focus:ring-emerald-500 bg-emerald-50/20" %>
<span class="absolute inset-y-0 right-2.5 flex items-center pointer-events-none"
data-field-suggestion-target="badge">
<span class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-semibold text-emerald-800">
added
</span>
</span>
</div>
<div class="mt-1.5 flex items-center justify-between text-xs text-amber-800 bg-amber-50 rounded px-2.5 py-1.5 border border-amber-200 hidden"
data-field-suggestion-target="suggestion">
<span>Account address: <strong><%= @user.address %></strong></span>
<button type="button" class="font-semibold text-blue-600 hover:text-blue-800 underline ml-2"
data-action="click->field-suggestion#apply"
data-field-suggestion-value-param="<%= @user.address %>">
Use this
</button>
</div>
</div>
<% elsif @lud16_state[:status] == :different %>
<div class="w-full sm:w-3/5 flex flex-col" data-controller="field-suggestion"
data-field-suggestion-added-value-value="<%= @lud16_state[:suggested] %>">
<div class="relative">
<%= f.text_field :lud16_address, value: @lud16_state[:value],
data: {
:'field-suggestion-target' => "input",
action: "input->field-suggestion#checkAdded"
},
class: "w-full pr-20 border-amber-400 ring-1 ring-amber-400 focus:border-amber-400 focus:ring-amber-400" %>
<span class="absolute inset-y-0 right-2.5 flex items-center pointer-events-none"
data-field-suggestion-target="badge">
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-800">
different
</span>
</span>
</div>
<div class="mt-1.5 flex items-center justify-between text-xs text-amber-800 bg-amber-50 rounded px-2.5 py-1.5 border border-amber-200"
data-field-suggestion-target="suggestion">
<span>Account address: <strong><%= @lud16_state[:suggested] %></strong></span>
<button type="button" class="font-semibold text-blue-600 hover:text-blue-800 underline ml-2"
data-action="click->field-suggestion#apply"
data-field-suggestion-value-param="<%= @lud16_state[:suggested] %>">
Use this
</button>
</div>
</div>
<% else %>
<%= f.text_field :lud16_address, value: @lud16_state[:value], class: "w-full sm:w-3/5" %>
<% end %>
<% if @validation_errors.present? && @validation_errors[:lud16_address].present? %>
<p class="error-msg mt-2"><%= @validation_errors[:lud16_address].first %></p>
<% end %>
@@ -2,9 +2,12 @@
module Settings
class NostrEditProfileComponent < ViewComponent::Base
def initialize(user:, profile_event:)
attr_reader :user, :profile, :profile_event, :display_name, :nip05_state, :lud16_state
def initialize(user:, profile_event: nil, validation_errors: nil)
@user = user
@profile_event = profile_event
@validation_errors = validation_errors
@profile = {}
@display_name = nil
@@ -12,6 +15,25 @@ module Settings
@profile = JSON.parse(profile_event["content"]) rescue {}
@display_name = @profile["display_name"] || @profile["displayName"]
end
@display_name ||= @user.display_name
@nip05_state = compute_field_state(@profile["nip05"])
@lud16_state = compute_field_state(@profile["lud16"])
end
private
def compute_field_state(current_value)
target_value = @user.address
if current_value.blank?
{ status: :added, value: target_value, suggested: nil }
elsif current_value == target_value
{ status: :matched, value: current_value, suggested: nil }
else
{ status: :different, value: current_value, suggested: target_value }
end
end
end
end
@@ -5,8 +5,23 @@
<ul data-relay-list-editor-target="list" class="mb-4 space-y-2">
<% @relays.each do |relay| %>
<li class="flex gap-x-2 items-center">
<input type="text" name="relay_urls[]" value="<%= relay[:url] %>" class="grow" />
<select name="relay_markers[]" class="shrink-0 w-32">
<div class="relative grow">
<% if relay[:added] %>
<input type="text" name="relay_urls[]" value="<%= relay[:url] %>"
data-added-value="<%= relay[:url] %>"
data-action="input->relay-list-editor#checkAdded"
class="w-full pr-16 border-emerald-500 ring-1 ring-emerald-500 focus:border-emerald-500 focus:ring-emerald-500 bg-emerald-50/20" />
<span class="absolute inset-y-0 right-2.5 flex items-center pointer-events-none"
data-relay-list-editor-target="addedBadge">
<span class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-semibold text-emerald-800">
added
</span>
</span>
<% else %>
<input type="text" name="relay_urls[]" value="<%= relay[:url] %>" class="w-full" />
<% end %>
</div>
<select name="relay_markers[]" class="shrink-0 w-36 sm:w-40">
<option value=""<%= %( selected="selected") if relay[:marker].nil? %>>Read & write</option>
<option value="read"<%= %( selected="selected") if relay[:marker] == "read" %>>Read only</option>
<option value="write"<%= %( selected="selected") if relay[:marker] == "write" %>>Write only</option>
@@ -21,8 +36,10 @@
<template data-relay-list-editor-target="template">
<li class="flex gap-x-2 items-center">
<input type="text" name="relay_urls[]" value="" class="grow" />
<select name="relay_markers[]" class="shrink-0 w-32">
<div class="relative grow">
<input type="text" name="relay_urls[]" value="" class="w-full" />
</div>
<select name="relay_markers[]" class="shrink-0 w-36 sm:w-40">
<option value="">Read & write</option>
<option value="read">Read only</option>
<option value="write">Write only</option>
@@ -5,27 +5,30 @@ module Settings
def initialize(nip65_event: nil)
@nip65_event = nip65_event
@relays = build_relays
ensure_site_relay
end
private
def build_relays
if @nip65_event.present?
@nip65_event["tags"]
.select { |t| t[0] == "r" }
.map { |t| { url: t[1], marker: t[2] } }
site_relay = Setting.nostr_relay_url
if @nip65_event.blank?
relays = Setting.nostr_discovery_relays.map { |url| { url: url, marker: nil, added: true } }
if site_relay.present?
relays.reject! { |r| r[:url] == site_relay }
relays.unshift({ url: site_relay, marker: nil, added: true })
end
relays
else
Setting.nostr_discovery_relays.map { |url| { url: url, marker: nil } }
existing = @nip65_event["tags"]
.select { |t| t[0] == "r" }
.map { |t| { url: t[1], marker: t[2], added: false } }
if site_relay.present? && existing.none? { |r| r[:url] == site_relay }
existing.unshift({ url: site_relay, marker: nil, added: true })
end
existing
end
end
def ensure_site_relay
site_relay_url = Setting.nostr_relay_url
return if site_relay_url.blank?
@relays.reject! { |r| r[:url] == site_relay_url }
@relays.unshift({ url: site_relay_url, marker: nil })
end
end
end
@@ -0,0 +1,63 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "input", "badge", "suggestion" ]
static values = { addedValue: String }
apply (event) {
event.preventDefault()
const value = event.params.value ?? this.addedValueValue
if (this.hasInputTarget && value !== undefined) {
this.inputTarget.value = value
this.checkAdded()
}
}
checkAdded () {
if (!this.hasInputTarget) return
const addedValue = this.hasAddedValueValue ? this.addedValueValue : null
if (!addedValue) return
const isMatch = (this.inputTarget.value.trim() === addedValue.trim())
const emeraldClasses = [
"border-emerald-500", "ring-1", "ring-emerald-500",
"focus:border-emerald-500", "focus:ring-emerald-500",
"bg-emerald-50/20"
]
const amberClasses = [
"border-amber-400", "ring-1", "ring-amber-400",
"focus:border-amber-400", "focus:ring-amber-400"
]
if (isMatch) {
this.inputTarget.classList.remove(...amberClasses)
this.inputTarget.classList.add(...emeraldClasses)
if (this.hasBadgeTarget) {
this.badgeTarget.innerHTML = `
<span class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-semibold text-emerald-800">
added
</span>
`.trim()
this.badgeTarget.classList.remove("hidden")
}
if (this.hasSuggestionTarget) {
this.suggestionTarget.classList.add("hidden")
}
} else {
this.inputTarget.classList.remove(...emeraldClasses)
if (this.hasBadgeTarget) {
this.badgeTarget.classList.add("hidden")
}
if (this.hasSuggestionTarget) {
this.suggestionTarget.classList.remove("hidden")
}
}
}
}
@@ -1,7 +1,7 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "list", "template" ]
static targets = [ "list", "template", "addedBadge" ]
add (event) {
event.preventDefault()
@@ -14,4 +14,27 @@ export default class extends Controller {
const row = event.target.closest("li")
row.remove()
}
checkAdded (event) {
const input = event.target
const addedValue = input.dataset.addedValue
if (!addedValue) return
const container = input.closest(".relative")
const badge = container?.querySelector("[data-relay-list-editor-target='addedBadge']")
const addedClasses = [
"border-emerald-500", "ring-1", "ring-emerald-500",
"focus:border-emerald-500", "focus:ring-emerald-500",
"bg-emerald-50/20"
]
if (input.value.trim() === addedValue.trim()) {
input.classList.add(...addedClasses)
if (badge) badge.classList.remove("hidden")
} else {
input.classList.remove(...addedClasses)
if (badge) badge.classList.add("hidden")
}
}
}
@@ -0,0 +1,22 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe FormElements::FieldsetComponent, type: :component do
it "renders a div with flex gap-x-1 for vertical positioning" do
rendered = render_inline(described_class.new(title: "Username")) do
"<input type='text' name='username' />".html_safe
end
expect(rendered.css("label.block > div.flex.gap-x-1")).to be_present
expect(rendered.css("label.block > p.flex")).not_to be_present
end
it "renders a div for horizontal positioning" do
rendered = render_inline(described_class.new(positioning: :horizontal, title: "Enable notifications")) do
"<input type='checkbox' />".html_safe
end
expect(rendered.css("label.block > div.relative")).to be_present
end
end
@@ -0,0 +1,86 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Settings::NostrEditProfileComponent, type: :component do
let(:user) { build(:user, cn: "alice") }
before do
allow(user).to receive(:address).and_return("alice@kosmos.org")
allow(user).to receive(:display_name).and_return("Alice Wonderland")
end
describe "when profile event is missing or fields are empty" do
it "prefills NIP-05 and Lightning addresses with user address and marks them as added" do
rendered = render_inline(described_class.new(user: user, profile_event: nil))
nip05_input = rendered.css("input[name='user[nip05_address]']").first
expect(nip05_input["value"]).to eq("alice@kosmos.org")
expect(nip05_input["class"]).to include("border-emerald-500")
expect(nip05_input["data-action"]).to include("input->field-suggestion#checkAdded")
lud16_input = rendered.css("input[name='user[lud16_address]']").first
expect(lud16_input["value"]).to eq("alice@kosmos.org")
expect(lud16_input["class"]).to include("border-emerald-500")
expect(lud16_input["data-action"]).to include("input->field-suggestion#checkAdded")
badges = rendered.css("span.rounded-full").select { |el| el.text.strip == "added" }
expect(badges.size).to eq(2)
end
end
describe "when profile has different NIP-05 and Lightning addresses" do
let(:profile_event) do
{
"content" => {
"nip05" => "alice@other.org",
"lud16" => "alice@getalby.com",
"display_name" => "Alice External"
}.to_json
}
end
it "shows current values with different badges and suggestion strips" do
rendered = render_inline(described_class.new(user: user, profile_event: profile_event))
nip05_input = rendered.css("input[name='user[nip05_address]']").first
expect(nip05_input["value"]).to eq("alice@other.org")
expect(nip05_input["class"]).to include("border-amber-400")
lud16_input = rendered.css("input[name='user[lud16_address]']").first
expect(lud16_input["value"]).to eq("alice@getalby.com")
expect(lud16_input["class"]).to include("border-amber-400")
different_badges = rendered.css("span.rounded-full").select { |el| el.text.strip == "different" }
expect(different_badges.size).to eq(2)
suggestion_buttons = rendered.css("button[data-action='click->field-suggestion#apply']")
expect(suggestion_buttons.size).to eq(2)
expect(suggestion_buttons.first["data-field-suggestion-value-param"]).to eq("alice@kosmos.org")
end
end
describe "when profile addresses match user address" do
let(:profile_event) do
{
"content" => {
"nip05" => "alice@kosmos.org",
"lud16" => "alice@kosmos.org",
"display_name" => "Alice Wonderland"
}.to_json
}
end
it "renders standard inputs without added or different badges" do
rendered = render_inline(described_class.new(user: user, profile_event: profile_event))
nip05_input = rendered.css("input[name='user[nip05_address]']").first
expect(nip05_input["value"]).to eq("alice@kosmos.org")
expect(nip05_input["class"]).not_to include("border-emerald-500")
expect(nip05_input["class"]).not_to include("border-amber-400")
badges = rendered.css("span.rounded-full").select { |el| %w[added different].include?(el.text.strip) }
expect(badges).to be_empty
end
end
end
@@ -0,0 +1,79 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Settings::NostrEditRelaysComponent, type: :component do
before do
allow(Setting).to receive(:nostr_relay_url).and_return("wss://nostr.kosmos.org")
allow(Setting).to receive(:nostr_discovery_relays).and_return([
"wss://nos.lol",
"wss://relay.damus.io"
])
end
describe "when user has no relay list (nip65_event is nil)" do
it "renders all relays with added status and badges" do
rendered = render_inline(described_class.new(nip65_event: nil))
expect(rendered.css("input[value='wss://nostr.kosmos.org']")).to be_present
expect(rendered.css("input[value='wss://nos.lol']")).to be_present
expect(rendered.css("input[value='wss://relay.damus.io']")).to be_present
badges = rendered.css("span.rounded-full").select { |el| el.text.strip == "added" }
expect(badges.size).to eq(3)
end
end
describe "when user has an existing relay list without site relay" do
let(:nip65_event) do
{
"kind" => 10002,
"tags" => [
["r", "wss://nos.lol"],
["r", "wss://relay.damus.io", "read"]
]
}
end
it "prepends the site relay marked as added, while existing relays are not added" do
rendered = render_inline(described_class.new(nip65_event: nip65_event))
relay_inputs = rendered.css("input[name='relay_urls[]']").map { |el| el["value"] }
expect(relay_inputs.first).to eq("wss://nostr.kosmos.org")
badges = rendered.css("span.rounded-full").select { |el| el.text.strip == "added" }
expect(badges.size).to eq(1)
site_input = rendered.css("input[value='wss://nostr.kosmos.org']").first
expect(site_input["class"]).to include("border-emerald-500")
expect(site_input["data-added-value"]).to eq("wss://nostr.kosmos.org")
expect(site_input["data-action"]).to include("input->relay-list-editor#checkAdded")
other_input = rendered.css("input[value='wss://nos.lol']").first
expect(other_input["class"]).not_to include("border-emerald-500")
expect(other_input["data-added-value"]).to be_nil
select = rendered.css("select[name='relay_markers[]']").first
expect(select["class"]).to include("w-36")
end
end
describe "when user already has site relay in their relay list" do
let(:nip65_event) do
{
"kind" => 10002,
"tags" => [
["r", "wss://nostr.kosmos.org"],
["r", "wss://nos.lol"]
]
}
end
it "renders relays without any added badges" do
rendered = render_inline(described_class.new(nip65_event: nip65_event))
badges = rendered.css("span.rounded-full").select { |el| el.text.strip == "added" }
expect(badges).to be_empty
end
end
end