diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 10 | ||||
| -rw-r--r-- | src/features/messages.js | 56 | ||||
| -rw-r--r-- | src/i18n.js | 2 | ||||
| -rw-r--r-- | src/style.css | 10 |
4 files changed, 76 insertions, 2 deletions
@@ -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; } } |
