aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-30 22:03:42 -0700
committerAdam Soltys <[email protected]>2026-08-30 22:03:42 -0700
commit46693593582a3123ca45c59b468a24807668fc7d (patch)
tree5f8436d137f7f830cc47b50dbba253a2282f780f /src
parent31748c85b02e725e75f3b4ebbef7c0cf17f1ecac (diff)
downloadcoinosv3-46693593582a3123ca45c59b468a24807668fc7d.tar.gz
coinosv3-46693593582a3123ca45c59b468a24807668fc7d.zip
A migrated coinos.io account keeps its face and its cover: the claim fetches the legacy avatar and banner and fills the kind 0's empty slots (fill, never overwrite — an existing nostr identity keeps its own look), the wizard's punk picker steps aside for a profile that already has a picture, and v3 finally wears cover photos — nostr's standard kind-0 banner renders full-bleed across the top of every profile card with the avatar overlapping it, settable by URL or upload in the profile editor
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MrkLsNScXe9b6BviUSdmMr
Diffstat (limited to 'src')
-rw-r--r--src/app.js10
-rw-r--r--src/features/messages.js56
-rw-r--r--src/i18n.js2
-rw-r--r--src/style.css10
4 files changed, 76 insertions, 2 deletions
diff --git a/src/app.js b/src/app.js
index 1a90546..3fb985f 100644
--- a/src/app.js
+++ b/src/app.js
@@ -3019,8 +3019,16 @@ function claimMigratedName() {
const attempt = () => {
const p = featureHook('namesClaimName', name);
if (!p || !p.then) { if (++tries < 12) return setTimeout(attempt, 5000); ui.migrating = null; return; }
- p.then(() => {
+ p.then(async () => {
try { localStorage.removeItem(MIGRATED_KEY); } catch {}
+ // Bring the old account's face and cover along — filled into empty
+ // kind-0 slots only. Bounded wait: the wizard's punk-picker skip
+ // checks the cached picture right after this, and losing the race
+ // would ask a migrated user to restyle the profile they already have.
+ await Promise.race([
+ Promise.resolve(featureHook('adoptLegacyProfile', name)).catch(() => {}),
+ new Promise((r) => setTimeout(r, 5000)),
+ ]);
ui.migrating = null;
// Mid-onboarding: the migrate step's job is done — move the wizard
// along instead of re-offering the page that was just completed. A
diff --git a/src/features/messages.js b/src/features/messages.js
index af22b8c..4451569 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1739,7 +1739,7 @@ export function messagesFeature(ctx) {
addr = want + '@' + oldAddr.split('@')[1];
}
const merged = { ...base };
- for (const [k, v] of [['name', e.name], ['about', e.about], ['picture', e.picture]]) {
+ for (const [k, v] of [['name', e.name], ['about', e.about], ['picture', e.picture], ['banner', e.banner]]) {
if (v.trim()) merged[k] = v.trim();
else delete merged[k];
}
@@ -1823,6 +1823,7 @@ export function messagesFeature(ctx) {
name: (full || {}).display_name || p.name || '',
about: p.about || '',
picture: p.picture || '',
+ banner: (full || {}).banner || '',
};
ui.profEditFilled = full !== undefined;
}
@@ -1834,6 +1835,7 @@ export function messagesFeature(ctx) {
if (!e.name) e.name = full.display_name || full.name || '';
if (!e.about) e.about = full.about || '';
if (!e.picture) e.picture = full.picture || '';
+ if (!e.banner) e.banner = full.banner || '';
}
const name = displayName(pk);
const npub = npubOf(pk) || pk;
@@ -1858,9 +1860,15 @@ export function messagesFeature(ctx) {
// an about of "~" or a lone character is noise, not a bio
const about = full && typeof full.about === 'string' ? full.about.trim() : '';
const showAbout = about.length > 1;
+ // The cover photo: nostr's standard kind-0 `banner`. Legacy coinos.io
+ // wore these proudly — migrated accounts bring theirs along, and anyone
+ // can set one in the editor below.
+ const bannerUrl = full && typeof full.banner === 'string' && /^https?:\/\//i.test(full.banner.trim())
+ ? full.banner.trim() : null;
return h('div', { class: 'col', style: 'gap:16px' },
ctx.brandHeader(false),
h('div', { class: 'card col', style: 'gap:12px' },
+ bannerUrl ? h('div', { class: 'profile-banner', style: `background-image:url(${JSON.stringify(bannerUrl)})` }) : null,
h('div', { class: 'row gap6', style: 'align-items:center' },
avatar(pk, 'chat-avatar profile-avatar', false),
h('div', { class: 'col grow', style: 'min-width:0;gap:2px' },
@@ -1914,6 +1922,24 @@ export function messagesFeature(ctx) {
ui.profUploading = false; render();
},
})),
+ field(t('profBanner'), 'banner', 'https://…'),
+ h('div', { class: 'row gap6' },
+ h('button', {
+ class: 'btn-sm', type: 'button', disabled: !!ui.profUploading,
+ onClick: () => document.getElementById('prof-banner-file')?.click(),
+ }, ui.profUploading ? h('span', { class: 'spinner sm' }) : t('profUploadBanner')),
+ h('input', {
+ id: 'prof-banner-file', type: 'file', accept: 'image/*', style: 'display:none',
+ onChange: async (e) => {
+ const f = e.target.files && e.target.files[0];
+ e.target.value = '';
+ if (!f || !ctx.uploadImage) return;
+ ui.profUploading = true; render();
+ try { if (ui.profEdit) ui.profEdit.banner = await ctx.uploadImage(f); }
+ catch (err) { toast(err.message); }
+ ui.profUploading = false; render();
+ },
+ })),
h('button', { class: 'btn-primary btn-block', disabled: ui.profSaving, onClick: saveProfile },
ui.profSaving ? h('span', { class: 'spinner sm' }) : t('save')),
logoutBtn())
@@ -2708,6 +2734,34 @@ export function messagesFeature(ctx) {
// Publish (merge) kind-0 fields for the current identity — the onboarding
// wizard sets name + picture through this.
publishProfile(fields, opts = {}) { return publishProfileFields(fields, opts); },
+ // A migrated coinos.io account brings its face with it: fetch the legacy
+ // avatar and cover art and fill our kind 0's EMPTY slots — fill, never
+ // overwrite, so an existing nostr identity keeps its own look. The light
+ // cache is pre-warmed so the wizard's punk-picker skip sees the picture
+ // before the publish round-trips.
+ async adoptLegacyProfile(username) {
+ try {
+ const u = await fetch(`https://coinos.io/api/users/${encodeURIComponent(username)}`).then((r) => r.json());
+ if (!u) return true;
+ const hosted = (v) => `https://coinos.io/api/public/${v}.webp`;
+ const picture = u.profile ? hosted(u.profile)
+ : typeof u.picture === 'string' && /^https?:\/\//i.test(u.picture) ? u.picture : null;
+ const banner = !u.banner ? null
+ : /^https?:\/\//i.test(String(u.banner)) ? String(u.banner) : hosted(u.banner);
+ if (!picture && !banner) return true;
+ const pk = myPubkeys()[0];
+ if (pk && picture) {
+ const cur = profiles.get(pk);
+ if (!(cur && cur.picture)) {
+ profiles.set(pk, { ...(cur || {}), picture, t: Date.now() });
+ preloadPicture({ picture });
+ scheduleRepaint();
+ }
+ }
+ await publishProfileFields({ picture, banner }, { fillOnly: ['picture', 'banner'] });
+ } catch {}
+ return true;
+ },
// 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.
diff --git a/src/i18n.js b/src/i18n.js
index 20777f2..35a8cdb 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -259,6 +259,8 @@ const STR = {
profName: 'Name',
profAbout: 'About',
profPicture: 'Picture URL',
+ profBanner: 'Cover photo URL',
+ profUploadBanner: 'Upload cover',
profSaved: 'Profile published',
hats: 'Hats',
hatShopSupport: 'Support Coinos, Buy a Hat!',
diff --git a/src/style.css b/src/style.css
index 412e3b9..7f71770 100644
--- a/src/style.css
+++ b/src/style.css
@@ -437,6 +437,15 @@ hr.divider { margin: 16px 0; }
.send-suggest { max-height: 260px; overflow-y: auto; }
.clickable { cursor: pointer; }
.profile-avatar { width: 64px; height: 64px; font-size: 18px; }
+/* profile cover: full-bleed across the top of the profile card; the avatar
+ row overlaps it so the face sits half on the cover, half on paper */
+.profile-banner {
+ height: 118px; margin: -20px -20px 0; background-size: cover; background-position: center;
+ border-radius: var(--radius) var(--radius) 0 0;
+}
+.profile-banner + .row .profile-avatar {
+ margin-top: -26px; border: 3px solid var(--paper); box-sizing: content-box;
+}
.zap-chip { display: inline-flex; gap: 5px; align-items: center; cursor: pointer; background: var(--surface2); border: 1px solid var(--line); border-radius: 999px; padding: 1px 8px 1px 2px; }
.chat-avatar.mini { width: 18px; height: 18px; font-size: 8px; flex-shrink: 0; }
.chat-avatar.fallback { position: relative; overflow: hidden; }
@@ -656,6 +665,7 @@ body::after {
@media (max-height: 620px) {
#app { padding-top: 12px; padding-bottom: 12px; }
.card { padding: 16px; }
+ .profile-banner { margin: -16px -16px 0; }
.footer { padding-top: 14px; padding-bottom: 0; }
.tabs { margin: 10px 0; }
}