WIP: Improve Nostr user metadata fetching

Use a multi-step process, caching results in between, while informing
the user about the current steps/progress
This commit is contained in:
2026-08-11 18:16:58 -06:00
parent 52742ba305
commit a9045fa413
9 changed files with 145 additions and 70 deletions
@@ -3,25 +3,14 @@
module Settings
class NostrEditProfileComponent < ViewComponent::Base
def initialize(user:, profile_event:)
if profile_event.present?
@user = user
@profile_event = profile_event
@profile = JSON.parse(profile_event["content"])
@user = user
@profile_event = profile_event
@profile = {}
@display_name = nil
if profile_event.present? && profile_event["content"].present?
@profile = JSON.parse(profile_event["content"]) rescue {}
@display_name = @profile["display_name"] || @profile["displayName"]
if @profile["nip05"].present? && @profile["nip05"] == @user.address
# "Your profile's Nostr address is set to <strong>#{ user_address }</strong>"
else
# "Your profile's Nostr address is not set to <strong>#{ user_address }</strong> yet"
end
if @profile["lud16"].present? && @profile["lud16"] == @user.address
# "Your profile's Lightning address is set to <strong>#{ user_address }</strong>"
else
# "Your profile's Lightning address is not set to <strong>#{ user_address }</strong> yet"
end
else
# "We could not find a profile for your public key"
end
end
end
@@ -2,9 +2,11 @@
module Settings
class NostrRelayStatusComponent < ViewComponent::Base
def initialize(nip65_event:)
def initialize(nip65_event:, relay_url: Setting.nostr_relay_url)
@relay_url = relay_url
if nip65_event.present?
if relay_urls(nip65_event).any? { |r| r.include?("wss://nostr.kosmos.org") }
if relay_urls(nip65_event).include?(@relay_url)
@text = "You have a relay list, and the Kosmos relay is part of it"
@icon_name = "check-circle"
@icon_color = "emerald-500"
+46 -17
View File
@@ -9,7 +9,7 @@ class SettingsController < ApplicationController
]
before_action :set_user, only: [
:show, :update, :update_email, :reset_email_password,
:fetch_nostr_user_metadata
:fetch_nostr_metadata
]
def index
@@ -148,26 +148,45 @@ class SettingsController < ApplicationController
}
end
def fetch_nostr_user_metadata
if @user.nostr_pubkey.present?
outbox_relay_urls = nil
def fetch_nostr_metadata
return render plain: "no pubkey", status: :bad_request if @user.nostr_pubkey.blank?
# if @nip65_event = NostrManager::DiscoverUserRelays.call(pubkey: @user.nostr_pubkey)
# relay_tags = @nip65_event["tags"].select{ |t| t[0] == "r" }
# outbox_relay_urls = relay_tags&.select{ |t| t[2] != "read" }&.map{ |t| t[1] }
# end
cache_key = "nostr_metadata:#{@user.id}"
case params[:step]
when "relays"
nip65_event = NostrManager::DiscoverUserRelays.call(pubkey: @user.nostr_pubkey)
write_relays = nip65_event ? extract_write_relays(nip65_event) : []
if nip65_event && write_relays.any? && stale?(nip65_event)
auth_event = NostrManager::DiscoverUserRelays.call(
pubkey: @user.nostr_pubkey, relays: write_relays
)
nip65_event = auth_event if auth_event
write_relays = extract_write_relays(nip65_event) if auth_event
end
Rails.cache.write("#{cache_key}:nip65", nip65_event, expires_in: 5.minutes)
Rails.cache.write("#{cache_key}:write_relays", write_relays, expires_in: 5.minutes)
render json: { next: "profile" }
when "profile"
write_relays = Rails.cache.read("#{cache_key}:write_relays") || []
relays = write_relays.any? ? write_relays : nil
profile = NostrManager::DiscoverUserProfile.call(
pubkey: @user.nostr_pubkey, relays: relays
)
Rails.cache.write("#{cache_key}:profile", profile, expires_in: 5.minutes)
render json: { next: "render" }
when "render", nil
@nip65_event = Rails.cache.read("#{cache_key}:nip65")
@profile = Rails.cache.read("#{cache_key}:profile")
render partial: "nostr_metadata", formats: :html
# @profile = NostrManager::DiscoverUserProfile.call(
# pubkey: @user.nostr_pubkey,
# relays: outbox_relay_urls
# )
@profile = {"content"=>"{\"name\":\"jimmy\",\"picture\":\"https://storage.kosmos.org/jimmy/public/shares/241028-1117-tony.jpg\",\"banner\":\"https://storage.kosmos.org/raucao/public/shares/240604-1517-1500x500.jpg\",\"nip05\":\"jimmy@kosmos.org\",\"lud16\":\"jimmy@kosmos.org\",\"pubkey\":\"07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3\",\"display_name\":\"Jimmy\",\"displayName\":\"Jimmy\",\"about\":\"I don't exist. Follow at your own peril.\"}", "created_at"=>1730114246, "id"=>"6b15b1308a61ee837bd3b50319978314650e435891c259f4ea499f819f35a4f6", "kind"=>0, "pubkey"=>"07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3", "sig"=>"4f681f4b95646bbf88a6eae9ca92c0f2ce5effecfa017556a23490f91a99243aedf81d956ee2466ed64fecb9a03b6b89cd80ff116df0178830977e203867d7ae", "tags"=>[]}
# @profile = {"content"=>"{\"name\":\"jimmy\",\"nip05\":\"jimmy@kosmos.org\",\"lud16\":\"jimmy@kosmos.org\",\"pubkey\":\"07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3\",\"display_name\":\"Jimmy\",\"displayName\":\"Jimmy\",\"about\":\"I don't exist. Follow at your own peril.\"}", "created_at"=>1730114246, "id"=>"6b15b1308a61ee837bd3b50319978314650e435891c259f4ea499f819f35a4f6", "kind"=>0, "pubkey"=>"07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3", "sig"=>"4f681f4b95646bbf88a6eae9ca92c0f2ce5effecfa017556a23490f91a99243aedf81d956ee2466ed64fecb9a03b6b89cd80ff116df0178830977e203867d7ae", "tags"=>[]}
else
@relays, @profile = [nil, nil]
render plain: "invalid step", status: :bad_request
end
render partial: 'nostr_user_metadata'
end
private
@@ -176,6 +195,16 @@ class SettingsController < ApplicationController
@current_section = :settings
end
def extract_write_relays(nip65_event)
nip65_event["tags"]
.select { |t| t[0] == "r" && t[2] != "read" }
.map { |t| t[1] }
end
def stale?(event, threshold = 30.minutes)
Time.at(event["created_at"]) < threshold.ago
end
def set_settings_section
@settings_section = params[:section]
allowed_sections = [
@@ -0,0 +1,50 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "status", "loading", "content" ]
static values = { url: String }
connect () {
this.runStep("relays")
}
async runStep (step) {
const messages = {
relays: "Looking up your relay list…",
profile: "Fetching your profile…",
blossom: "Fetching your media server list…",
render: null
}
if (messages[step]) {
this.statusTarget.textContent = messages[step]
}
try {
const headers = (step === "render")
? { "Accept": "text/html" }
: { "Accept": "application/json" }
const res = await fetch(`${this.urlValue}?step=${step}`, { headers })
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
if (step === "render") {
const html = await res.text()
this.loadingTarget.classList.add("hidden")
this.contentTarget.classList.remove("hidden")
this.contentTarget.innerHTML = html
} else {
const data = await res.json()
if (data.next) {
this.runStep(data.next)
}
}
} catch (error) {
console.warn("Nostr metadata fetch failed:", error.message)
this.statusTarget.textContent = "Something went wrong. Please reload."
}
}
}
@@ -1,8 +1,8 @@
module NostrManager
class DiscoverUserRelays < NostrManagerService
def initialize(pubkey:)
def initialize(pubkey:, relays: nil)
@pubkey = pubkey
@relays = Setting.nostr_discovery_relays
@relays = relays.present? ? relays : Setting.nostr_discovery_relays
end
def call
+10 -3
View File
@@ -88,8 +88,15 @@
</section>
<% if current_user.nostr_pubkey.present? %>
<%= turbo_frame_tag "nostr_user_metadata", src: nostr_user_metadata_settings_path do %>
<p>Loading...</p>
<% end %>
<div data-controller="nostr-metadata"
data-nostr-metadata-url-value="<%= nostr_metadata_settings_path %>">
<div data-nostr-metadata-target="loading"
class="flex flex-col items-center justify-center py-16">
<div class="mb-4 h-12 w-12"></div>
<p data-nostr-metadata-target="status"
class="text-gray-500 animate-pulse">Loading…</p>
</div>
<div data-nostr-metadata-target="content" class="hidden"></div>
</div>
<% end %>
</div>
@@ -0,0 +1,25 @@
<section class="">
<h3>Profile</h3>
<%= render Settings::NostrProfileStatusComponent.new(
profile_event: @profile,
user_address: current_user.address
) %>
<div class="mt-8" data-controller="modal" data-action="keydown.esc->modal#close">
<button data-action="click->modal#open" class="btn-md btn-blue w-full sm:w-auto">
Edit profile
</button>
<%= render ModalComponent.new(show_close_button: false) do %>
<%= render Settings::NostrEditProfileComponent.new(
user: current_user,
profile_event: @profile
) %>
<% end %>
</div>
</section>
<section class="mb-8 sm:mb-12">
<h3>Relays</h3>
<%= render Settings::NostrRelayStatusComponent.new(
nip65_event: @nip65_event
) %>
</section>
@@ -1,27 +0,0 @@
<%= turbo_frame_tag "nostr_user_metadata" do %>
<section>
<h3>Relays</h3>
<%= render Settings::NostrRelayStatusComponent.new(
nip65_event: @nip65_event
) %>
</section>
<section>
<h3>Profile</h3>
<%= render Settings::NostrProfileStatusComponent.new(
profile_event: @profile,
user_address: current_user.address
) %>
<div class="mt-8" data-controller="modal" data-action="keydown.esc->modal#close">
<button data-action="click->modal#open" class="btn-md btn-blue w-full sm:w-auto">
Edit profile
</button>
<%= render ModalComponent.new(show_close_button: false) do %>
<%= render Settings::NostrEditProfileComponent.new(
user: current_user,
profile_event: @profile
) %>
<% end %>
</div>
</section>
<% end %>
+1 -1
View File
@@ -68,7 +68,7 @@ Rails.application.routes.draw do
post 'reset_email_password'
post 'set_nostr_pubkey'
delete 'nostr_pubkey', to: 'settings#remove_nostr_pubkey'
get 'fetch_nostr_user_metadata', as: 'nostr_user_metadata'
get 'fetch_nostr_metadata', as: 'nostr_metadata'
end
end