aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/messages.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/messages.js')
-rw-r--r--src/features/messages.js107
1 files changed, 80 insertions, 27 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index a35c0da..c82bd0e 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -12587,12 +12587,32 @@ export function messagesFeature(ctx) {
...noteOverlays());
}
- async function publishProfileFields(fields, opts = {}) {
+ // Every kind-0 publish merges over the newest copy on the relays, so two
+ // publishes in flight at once clobber each other: both read the same base,
+ // the later one lands without the earlier one's fields. That exact race
+ // put a migrated user's released placeholder name back into their lud16
+ // (the rename repointed it, the legacy-avatar import republished the old
+ // base nine seconds later). One at a time, and the base is never older
+ // than what this session last wrote.
+ let kind0Chain = Promise.resolve();
+ let kind0Last = null; // { at, content } — the newest kind 0 we published
+ const withKind0Lock = (fn) => { const p = kind0Chain.then(fn, fn); kind0Chain = p.catch(() => {}); return p; };
+ async function newestKind0(pk) {
+ const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [pk] }, 3000);
+ let newest = evs.sort((a, b) => b.created_at - a.created_at)[0] || null;
+ if (kind0Last && kind0Last.pk === pk && (!newest || newest.created_at <= kind0Last.at)) newest = kind0Last;
+ let base = {};
+ try { base = newest ? JSON.parse(newest.content) : {}; } catch {}
+ return base;
+ }
+ // Strictly later than the last one we sent: relays keep the LOWEST id of
+ // two replaceable events with the same second, so a second publish in
+ // the same second as the first could lose to it.
+ const kind0Stamp = (pk) => { const now = Math.floor(Date.now() / 1000); return kind0Last && kind0Last.pk === pk ? Math.max(now, kind0Last.at + 1) : now; };
+ function publishProfileFields(fields, opts = {}) { return withKind0Lock(() => publishProfileFieldsNow(fields, opts)); }
+ async function publishProfileFieldsNow(fields, opts = {}) {
const id = await requireIdentity();
- const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000);
- const newest = evs.sort((a, b) => b.created_at - a.created_at)[0];
- let base = {};
- try { base = newest ? JSON.parse(newest.content) : {}; } catch {}
+ const base = await newestKind0(id.pubkey);
if (opts.onlyWhen && !opts.onlyWhen(base)) return true; // condition says leave it be
const merged = { ...base };
// fillOnly fields are offers, not orders: the onboarding wizard suggests
@@ -12608,10 +12628,11 @@ export function messagesFeature(ctx) {
}
if (merged.name && !(fill.has('name') && taken('name'))) merged.display_name = merged.name;
if (JSON.stringify(merged) === JSON.stringify(base)) return true; // nothing to say
- const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) };
+ const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt);
if (!ok) throw new Error(t('msgSendFailed'));
+ kind0Last = { pk: id.pubkey, at: evt.created_at, content: evt.content };
fullProfiles.set(id.pubkey, merged);
profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null, t: Date.now() });
return true;
@@ -12625,11 +12646,20 @@ export function messagesFeature(ctx) {
ui.profSaving = true;
render();
try {
+ await withKind0Lock(() => saveProfileNow(id, e));
+ ui.profEdit = null; ui.profEditFilled = false;
+ toast(t('profSaved'));
+ } catch (err) {
+ if (ui.profEdit === e && e.uname === err.migrationName) e.migrationName = err.migrationName;
+ toast(err.message || String(err));
+ } finally {
+ ui.profSaving = false;
+ render();
+ }
+ }
+ async function saveProfileNow(id, e) {
// merge over the newest published kind 0 so unknown fields round-trip
- const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000);
- const newest = evs.sort((a, b) => b.created_at - a.created_at)[0];
- let base = {};
- try { base = newest ? JSON.parse(newest.content) : {}; } catch {}
+ const base = await newestKind0(id.pubkey);
// A changed username claims first — if the name is taken (or invalid)
// the save stops here and the editor stays open to fix it.
const oldAddr = hook('namesAddress');
@@ -12653,22 +12683,46 @@ export function messagesFeature(ctx) {
if (!merged[k] || merged[k] === oldAddr) merged[k] = addr;
}
if (merged.name) merged.display_name = merged.name;
- const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) };
+ const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt);
if (!ok) throw new Error(t('msgSendFailed'));
+ kind0Last = { pk: id.pubkey, at: evt.created_at, content: evt.content };
fullProfiles.set(id.pubkey, merged);
profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null });
- ui.profEdit = null; ui.profEditFilled = false;
- toast(t('profSaved'));
- } catch (err) {
- if (ui.profEdit === e && e.uname === err.migrationName) e.migrationName = err.migrationName;
- toast(err.message || String(err));
- } finally {
- ui.profSaving = false;
- render();
+ }
+ // A payment-address rename released the old name: repoint the kind 0's
+ // lud16 and nip05 — but only where they pointed at the released address
+ // (or were empty). Deliberately different values are not ours to touch.
+ function repointAddress(oldAddr, newAddr) {
+ const follows = (k) => (base) => !base[k] || base[k] === oldAddr;
+ return publishProfileFields({ lud16: newAddr, nip05: newAddr }, {
+ onlyWhen: (base) => ['lud16', 'nip05'].some((k) => follows(k)(base) && base[k] !== newAddr),
+ fieldWhen: { lud16: follows('lud16'), nip05: follows('nip05') },
+ });
+ }
+ // Our kind 0 still names an address at our own domain that is not the one
+ // we hold — a placeholder name released by a migration whose repoint lost
+ // the race above. Repointed once the domain confirms the old name is dead;
+ // a same-domain name that still answers (a second account, say) is left
+ // alone. Once per address per session.
+ const addrHealed = new Set();
+ async function healStaleAddress(olds, myAddr) {
+ for (const old of olds) {
+ if (addrHealed.has(old)) continue;
+ addrHealed.add(old);
+ try { if (await addressDead(old)) await repointAddress(old, myAddr); } catch {}
}
}
+ async function addressDead(addr) {
+ const [name, domain] = addr.split('@');
+ try {
+ const r = await fetch(`https://${domain}/.well-known/lnurlp/${encodeURIComponent(name)}`, { cache: 'no-store' });
+ if (!r.ok) return true;
+ const j = await r.json().catch(() => null);
+ return !j || j.status === 'ERROR' || !j.callback;
+ } catch { return false; } // unreachable is not dead
+ }
function profileScreen() {
const pk = ui.profilePk;
@@ -12808,7 +12862,12 @@ export function messagesFeature(ctx) {
// authoritative locally, a deep link carries the address in the URL
// (seeded as `addr`), and the light cache remembers it from last time.
const lp = profileOf(pk) || {};
- const rawNip05 = (full && full.nip05) || (mine && myAddr) || lp.nip05 || lp.addr || null;
+ // Our own page never shows a name at our domain that the registrar says
+ // is no longer ours (see healStaleAddress): the claimed address is the
+ // truth here, and the kind 0 is brought in line in the background.
+ const stale = (v) => mine && myAddr && typeof v === 'string' && v !== myAddr && v.split('@')[1] === myAddr.split('@')[1] && !/^_@/.test(v) ? v : null;
+ if (full) { const olds = [...new Set([stale(full.lud16), stale(full.nip05)].filter(Boolean))]; if (olds.length) healStaleAddress(olds, myAddr); }
+ const rawNip05 = (full && (stale(full.nip05) ? myAddr : full.nip05)) || (mine && myAddr) || lp.nip05 || lp.addr || null;
const nip05 = rawNip05 ? String(rawNip05).replace(/^_@/, '') : null;
const lud16 = (full && full.lud16 ? String(full.lud16) : null) || lp.lud16 || null;
const showLud = lud16 && !/^npub1/i.test(lud16) && lud16 !== nip05;
@@ -18255,13 +18314,7 @@ export function messagesFeature(ctx) {
// A payment-address rename released the old name: repoint the kind 0's
// lud16 and nip05 — but only where they pointed at the released address
// (or were empty). Deliberately different values are not ours to touch.
- addressRenamed(oldAddr, newAddr) {
- const follows = (k) => (base) => !base[k] || base[k] === oldAddr;
- return publishProfileFields({ lud16: newAddr, nip05: newAddr }, {
- onlyWhen: (base) => ['lud16', 'nip05'].some((k) => follows(k)(base) && base[k] !== newAddr),
- fieldWhen: { lud16: follows('lud16'), nip05: follows('nip05') },
- }).catch(() => {});
- },
+ addressRenamed(oldAddr, newAddr) { return repointAddress(oldAddr, newAddr).catch(() => {}); },
// opens the profile, unless the caller has a step to take first (the
// Accounts page switches to that identity before showing its profile)
profileChip(pk, size, onOpen) {