diff options
| -rw-r--r-- | build.js | 2 | ||||
| -rw-r--r-- | src/features/messages.js | 14 | ||||
| -rw-r--r-- | src/i18n.js | 3 | ||||
| -rw-r--r-- | src/locales/it.json | 3 | ||||
| -rw-r--r-- | src/public-chat.js | 9 | ||||
| -rw-r--r-- | tools/public-chat-join-test.js | 75 |
6 files changed, 100 insertions, 6 deletions
@@ -450,7 +450,7 @@ function bootShell(logo, staging) { : '<span class="beta-badge">v3 beta</span>'; return `<div class="col" style="gap:16px"><div class="row between"><div class="brand ${staging ? 'staging' : 'beta'}">${badge}<div class="logo-full" aria-label="coinos" role="img">${logo}</div></div></div><div class="card col" id="boot-shell" style="gap:12px;display:none"><div class="row gap6" style="align-items:center"><div class="chat-avatar profile-avatar fallback loading"></div><div class="chat-title" id="boot-shell-name"></div></div></div></div>`; } -const BOOT_SHELL_SCRIPT = `<script>try{var m=location.pathname.match(/^\\/([A-Za-z0-9._-]{1,64})\\/?$/);if(m&&!/^(note|nevent|naddr)1/i.test(m[1])){document.getElementById('boot-shell').style.display='';document.getElementById('boot-shell-name').textContent=decodeURIComponent(m[1]);}}catch(e){}</script>`; +const BOOT_SHELL_SCRIPT = `<script>try{var m=location.pathname.match(/^\\/([A-Za-z0-9._-]{1,64})\\/?$/);if(m&&!/^(chat|pos|feed)$/.test(m[1])&&!/^(note|nevent|naddr)1/i.test(m[1])){document.getElementById('boot-shell').style.display='';document.getElementById('boot-shell-name').textContent=decodeURIComponent(m[1]);}}catch(e){}</script>`; // COINOS_NO_SW=1: the page registers no service worker. The Android bundled // build serves this page from its own assets, where a worker would only diff --git a/src/features/messages.js b/src/features/messages.js index b491a5c..592c5dc 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -2332,6 +2332,13 @@ export function messagesFeature(ctx) { // An invite link opened in the browser lands here before the wallet exists. const urlInvite = typeof location !== 'undefined' ? parseInviteLink(location.href) : null; if (urlInvite) { try { history.replaceState(null, '', '/'); } catch {} } + // /?chat: the public chat page's button on a device that has an account. + // It opens the coinos community's room itself once the wallet is open + // (init below), where "Open coinos" used to leave a signed-in reader on + // the wallet to find the chat (Andy, 2026-11-10). + let urlChat = typeof location !== 'undefined' && !urlInvite && location.pathname === '/' + && /(?:^|&)chat(?:=|&|$)/.test((location.search || '').slice(1)); + if (urlChat) { try { history.replaceState(null, '', '/'); } catch {} } // coinos's own pack share link, /emojis/pack/<naddr> — the Android app // owns every v3.coinos.io path, so one tapped on a phone lands here, not // in a browser; added once a wallet is open (init below). @@ -19130,6 +19137,13 @@ export function messagesFeature(ctx) { loadLinkInvite(urlInvite); setTimeout(() => { ui.chatOpen = true; ui.msgView = 'home'; render(); }, 0); } + if (urlChat) { + urlChat = false; // once: init runs again for every wallet born in this tab + setTimeout(() => { + ui.chatOpen = true; ui.msgView = 'room'; ui.msgCommunity = COMMUNITY.community_id; + ui.msgChannel = null; ui.msgStick = true; render(); + }, 0); + } if (urlPack) { addPack(urlPack).catch(() => {}); setTimeout(() => { ui.chatOpen = true; ui.msgView = 'home'; render(); }, 0); diff --git a/src/i18n.js b/src/i18n.js index af0bdfb..06afc11 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -709,7 +709,8 @@ const STR = { publicChatTitle: '{name} community', publicChatDesc: 'Live chat: read along, sign in to join the conversation', publicChatSignIn: 'Sign in to chat', - publicChatOpenApp: 'Open coinos', + publicChatDescMember: 'Live chat: open the chat in Coinos to join the conversation', + publicChatOpenApp: 'Join the chat', publicChatLoading: 'Loading messages…', msgDelete: 'Delete message', msgNoIdentity: "Your nostr signer isn't connected. Reconnect it to send as yourself", diff --git a/src/locales/it.json b/src/locales/it.json index a9998c1..30b05a8 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -486,7 +486,8 @@ "publicChatTitle": "Community {name}", "publicChatDesc": "Chat dal vivo: leggi, accedi per partecipare alla conversazione", "publicChatSignIn": "Accedi per chattare", - "publicChatOpenApp": "Apri coinos", + "publicChatDescMember": "Chat dal vivo: apri la chat in Coinos per partecipare alla conversazione", + "publicChatOpenApp": "Partecipa alla chat", "publicChatLoading": "Caricamento messaggi…", "msgDelete": "Elimina messaggio", "msgNoIdentity": "Il tuo signer nostr non è connesso. Riconnettilo per inviare a tuo nome", diff --git a/src/public-chat.js b/src/public-chat.js index 9cce6f5..dd54342 100644 --- a/src/public-chat.js +++ b/src/public-chat.js @@ -213,7 +213,9 @@ export function mountPublicChat() { const hasAccount = (() => { try { - return !!(sessionStorage.getItem('btc-wallet-accounts') || (JSON.parse(localStorage.getItem('btc-wallet-watch') || '[]')).length); + // a session in this tab, a saved wallet (a tab opened from a link has no session yet), or a watch-only one + return !!(sessionStorage.getItem('btc-wallet-accounts') || localStorage.getItem('btc-wallet-vault') + || (JSON.parse(localStorage.getItem('btc-wallet-watch') || '[]')).length); } catch { return false; } })(); @@ -306,8 +308,9 @@ export function mountPublicChat() { h('div', { class: 'row between', style: 'align-items:center;gap:10px' }, h('div', { class: 'col', style: 'gap:2px;min-width:0' }, h('div', { class: 'chat-title' }, t('publicChatTitle', { name: COMMUNITY.name })), - h('div', { class: 'small muted' }, t('publicChatDesc'))), - h('button', { class: 'btn-primary btn-sm', style: 'flex-shrink:0', onClick: () => { location.href = '/'; } }, + h('div', { class: 'small muted' }, t(hasAccount ? 'publicChatDescMember' : 'publicChatDesc'))), + // with an account on the device the button opens the room itself (/?chat) + h('button', { class: 'btn-primary btn-sm', style: 'flex-shrink:0', onClick: () => { location.href = hasAccount ? '/?chat' : '/'; } }, hasAccount ? t('publicChatOpenApp') : t('publicChatSignIn'))), tabs), log)); diff --git a/tools/public-chat-join-test.js b/tools/public-chat-join-test.js new file mode 100644 index 0000000..3f0eee4 --- /dev/null +++ b/tools/public-chat-join-test.js @@ -0,0 +1,75 @@ +// The public chat page (/chat) on a device that already has a wallet: its +// button opens the coinos community's room itself, not the wallet, and the +// page doesn't tell a signed-in reader to sign in. And /chat's loading shell +// is not a profile named "chat". (Andy, 2026-11-10: "I can see the feed etc +// fine, so I am logged in".) Relays are the stand-ins: nothing is sent. +// +// Run: bun tools/public-chat-join-test.js +import puppeteer from './puppeteer.js'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { buildHtml } from '../build.js'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5247, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.setViewport({ width: 390, height: 844 }); +const click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, t); +const waitFor = async (fn, ms = 15000, ...a) => { for (let i = 0; i < ms / 200; i++) { try { if (await page.evaluate(fn, ...a)) return true; } catch {} await sleep(200); } return false; }; +// did the static profile shell ever show while the page loaded? +await page.evaluateOnNewDocument(() => { + window.__shellShown = false; + const look = () => { const s = document.getElementById('boot-shell'); if (s && s.style.display !== 'none') window.__shellShown = true; }; + new MutationObserver(look).observe(document, { childList: true, subtree: true, attributes: true }); + document.addEventListener('DOMContentLoaded', look); +}); +const head = () => page.evaluate(() => { + const c = document.querySelector('.chat-head'); + return c ? { text: c.innerText, button: c.querySelector('button')?.textContent.trim() } : null; +}); + +try { + console.log('\n[a visitor]'); + await page.goto('http://localhost:5247/chat', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitFor(() => !!document.querySelector('.chat-head')); + let hd = await head(); + check('the public chat mounts', !!hd); + check('a visitor is asked to sign in', hd?.button === 'Sign in to chat' && /sign in to join/i.test(hd.text), hd?.button); + check("/chat's loading shell is not a profile", !(await page.evaluate(() => window.__shellShown))); + await page.goto('http://localhost:5247/someone', { waitUntil: 'domcontentloaded' }); + check('a name still gets its profile shell', await page.evaluate(() => window.__shellShown)); + + console.log('\n[with a wallet on the device]'); + await page.goto('http://localhost:5247/', { waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('button', 'Create a new wallet'); + await sleep(300); + await click('button', 'Have an existing seed'); + await sleep(300); + await page.waitForSelector('textarea'); + await page.type('textarea', generateMnemonic(wordlist)); + await click('button', 'Open wallet'); + check('wallet opens', await waitFor(() => /receive/i.test(document.body.innerText))); + await page.goto('http://localhost:5247/chat', { waitUntil: 'domcontentloaded' }); + await waitFor(() => !!document.querySelector('.chat-head')); + hd = await head(); + check('no "sign in" for someone signed in', !!hd && !/sign in/i.test(hd.text), hd?.text.replace(/\n/g, ' | ')); + check('the button offers the chat', hd?.button === 'Join the chat', hd?.button); + await Promise.all([page.waitForNavigation({ waitUntil: 'domcontentloaded' }), click('.chat-head button', 'Join the chat')]); + const inRoom = await waitFor(() => !!document.querySelector('.chat-compose textarea, .chat-compose input, .chat-compose [contenteditable]'), 20000); + check('it lands in the community room, composer ready', inRoom, await page.evaluate(() => document.body.innerText.slice(0, 120).replace(/\n/g, ' | '))); + check('the room is the coinos community', await page.evaluate(() => /coinos/i.test(document.querySelector('.chat-head, .chat-title')?.innerText || ''))); + check('the marker is gone from the address', await page.evaluate(() => location.pathname === '/' && !location.search)); +} finally { + await browser.close(); + server.stop(true); +} +console.log(ok ? '\nPASS' : '\nFAIL'); +process.exit(ok ? 0 : 1); |
