openvibers/openvibe.surf Commits

Share: one Share sheet on map and party pages (#81)

OP OpenVibers committed 7 days ago · 518d25a739 · 7 files, +15 −21

* Share: the network's one Share sheet on map and party pages

Replace the watch party's Copy link button and the plain share line with
the unified data-ov-share-sheet button (kit.share), and give each surf
map's page a Share button beside its title. The old clipboard handler,
markup and CSS are gone.

* Share: the shared kit button, one look everywhere

Render the one Share button with openvibe-shared 4.114.0's kit.share
(or its exact markup where the page is built in the browser), so every
site shows the same quiet button with the share icon. Remove the
site-specific Share styles.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 8b81957..a9d34bc 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,7 +6,7 @@ What changed in OpenVibe.Surf, newest first. Each site also publishes its patch
- **Watch together** (`server/watch/party.js`, `server/http/party.js`, `server/views/index.js`,
`public/js/surf.js`): a signed-in person opens a party (`POST /watch/party`) and shares an unguessable
-- `/watch/p/<id>` link. The page reuses the TV markup with a `sf-party-` bar (members, host, Copy link, Leave); the
++ `/watch/p/<id>` link. The page reuses the TV markup with a `sf-party-` bar (members, host, Share, Leave); the
host commands `{ channel_key, position_s, playing, at }` from this site only, members follow over SSE
(`GET /watch/p/<id>/events`) and correct drift over 2 s, and anyone can leave. State is one in-process Map,
capped at 200 parties and 50 members each; an empty party ends 30 minutes after its last member leaves, and a
ddiff --git a/README.md b/README.md
iindex 92d6ae0..b2cd182 100644
---- a/README.md
++++ b/README.md
@@@ -70,7 +70,7 @@ Every card is one shape:
A signed-in person opens a party (`POST /watch/party`, from this site) and gets an unguessable id at
`/watch/p/<id>`; that link is the invitation. Opening it sends a guest to sign in first and joins everyone else.
--The page reuses the TV markup with a `sf-party-` bar: members, host, **Copy link**, **Leave**, and, if the host
++The page reuses the TV markup with a `sf-party-` bar: members, host, **Share** (the network's one Share sheet), **Leave**, and, if the host
pasted one, a link to an OpenVibe.Chat room (`https://openvibe.chat/...`, validated). There is no chat inside Surf.
- **Host:** the person who opened the party. Only the host commands, with
@@@ -129,7 +129,7 @@ its status and prints its words. It is `private, no-store`; a `GET /web/go` star
| `/ride` | the maps with tier, stages, runs and the record, and the embed of the first map |
| `/ride/:map` | one map: name, tier, stages, records if Games answers, the embed |
| `/watch` | the TV: the screen plays clips and moments in the page (HLS), the guide lists every channel; ↑ ↓, a swipe or a tap changes channel; a live stream that records to MP4 plays over Media's DVR playlist, a little behind live, and any other opens on its channel; **Watch together** opens a party |
--| `/watch/p/:id` | a watch party: the same TV, a `sf-party-` bar with the members, host, Copy link and Leave, and the optional OpenVibe.Chat room link; signed-in members follow the host's channel and position over SSE |
++| `/watch/p/:id` | a watch party: the same TV, a `sf-party-` bar with the members, host, Share and Leave, and the optional OpenVibe.Chat room link; signed-in members follow the host's channel and position over SSE |
| `/web` | the Surf with Vibe form, examples, and what happens next |
| `/developers` | the API, for people and agents |
| anything else | the 404 page |
ddiff --git a/public/css/surf.css b/public/css/surf.css
iindex 7d2aed9..867d93f 100644
---- a/public/css/surf.css
++++ b/public/css/surf.css
@@@ -161,8 +161,6 @@ html.ov-motion .sf-party-dot { animation: sf-party-pulse 2.4s ease-in-out infini
@keyframes sf-party-pulse { 50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--live-red, #e11d48) 5%, transparent); } }
.sf-party-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.sf-party-actions form { margin: 0; }
--.sf-party-share { margin: 0; overflow-wrap: anywhere; }
--.sf-party-share a { color: var(--room-strong); font-weight: 700; }
.sf-party-chat { margin: 0; }
.sf-party-chat[hidden] { display: none; }
.sf-party-chat a { display: inline-flex; align-items: center; gap: 6px; color: var(--room-strong); font-weight: 800; text-decoration: none; }
@@@ -209,9 +207,8 @@ html[data-ov-motion=reduced] .sf-run-speed i { animation: none !important; }
html.ov-motion .sf-party-playing .sf-party-dot { animation: sf-pulse 1.6s ease-out infinite; }
@keyframes sf-pulse { to { box-shadow: 0 0 0 10px transparent; } }
.sf-party-is-host { padding: 1px 8px; border-radius: 999px; font-size: .72rem; font-weight: 800; color: var(--room-on); background: var(--room-fill); }
--.sf-party-actions, .sf-party-share { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
++.sf-party-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.sf-party-actions form { margin: 0; }
--.sf-party-url, .sf-party-link { max-width: 260px; min-width: 0; padding: 7px 10px; border-radius: 10px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); color: var(--text-secondary, #96a7c2); font: 500 .8rem ui-monospace, SFMono-Regular, Menlo, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sf-party-chat { margin: 10px 0 0; } .sf-party-chat a { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; }
.sf-party-chat-form { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.sf-party-chat-url { flex: 1 1 240px; min-height: 38px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: inherit; font: inherit; }
ddiff --git a/public/js/surf.js b/public/js/surf.js
iindex 89d91de..e47b1e9 100644
---- a/public/js/surf.js
++++ b/public/js/surf.js
@@@ -295,16 +295,8 @@
}
}, { passive: true });
-- // Party bar: Copy link, the optional Chat room link, and the live member count.
++ // Party bar: the optional Chat room link, and the live member count.
if (party) {
-- var copy = party.querySelector('[data-sf-party-copy]');
-- if (copy) copy.addEventListener('click', function () {
-- var path = copy.getAttribute('data-sf-party-url') || partyPath;
-- var url = location.origin + path;
-- if (navigator.clipboard && navigator.clipboard.writeText) {
-- navigator.clipboard.writeText(url).then(function () { say('Party link copied.'); }, function () { window.prompt('Copy this party link', url); });
-- } else window.prompt('Copy this party link', url);
-- });
var chatForm = party.querySelector('[data-sf-party-chat-form]');
if (chatForm) chatForm.addEventListener('submit', function (e) {
e.preventDefault();
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 5b11f6f..b78554b 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -132,10 +132,12 @@ ${first ? `<section id="sf-ride-embed" aria-labelledby="sf-embed-h" class="sf-pl
function map({ id, map: m = null, top = [], games = 'ready' } = {}) {
const one = m || { id };
const name = one.name || String(id || '');
++ const mapPath = `/ride/${encodeURIComponent(id || one.id || '')}`;
const rows = (Array.isArray(top) ? top : []).map((r, i) => `<tr${i === 0 ? ' class="is-wr"' : ''}><td>${esc(r.rank == null ? i + 1 : r.rank)}</td><td>${esc(r.name || '')}</td><td class="sf-time">${esc(runTime(r.timeMs) || '')}</td></tr>`).join('');
const body = `<div class="sf-wrap sf-page-pad">
<header class="sf-page-h sf-map-h" id="sf-map" data-sf-map-id="${esc(id || one.id || '')}"><p class="sf-kicker"><a href="/ride">Ride</a> · ${esc(tierLabel(one.tier))}</p><h1>${esc(name)}</h1>
--<p class="sf-lede">${one.stages == null ? '' : `${esc(one.stages)} stage${Number(one.stages) === 1 ? '' : 's'}`}${one.records != null ? ` · ${esc(one.records)} run${Number(one.records) === 1 ? '' : 's'}` : ''}${one.author ? ` · by ${esc(one.author)}` : ''}${one.wrMs != null ? ` · record <b>${esc(runTime(one.wrMs))}</b>${one.wrHolder ? ` by ${esc(one.wrHolder)}` : ''}` : ''}</p></header>
++<p class="sf-lede">${one.stages == null ? '' : `${esc(one.stages)} stage${Number(one.stages) === 1 ? '' : 's'}`}${one.records != null ? ` · ${esc(one.records)} run${Number(one.records) === 1 ? '' : 's'}` : ''}${one.author ? ` · by ${esc(one.author)}` : ''}${one.wrMs != null ? ` · record <b>${esc(runTime(one.wrMs))}</b>${one.wrHolder ? ` by ${esc(one.wrHolder)}` : ''}` : ''}</p>
++<p>${kit.share({ title: name, url: `${layoutLib.baseUrl()}${mapPath}`, text: `${name}: a surf map you ride in the browser, with stages, a millisecond timer and records.` })}</p></header>
${games === 'not_ready' ? `<p class="sf-note" id="sf-ride-not-ready">${esc(gamesNote(games))}</p>` : ''}
<div class="sf-map-grid">
<section id="sf-map-embed" aria-label="Play">${gameFrame(one, name) || `<div class="sf-empty">${kit.icon('game')}<p>This map cannot be played here right now.</p></div>`}</section>
@@@ -196,7 +198,7 @@ ${tvBlock({ feed })}
return layout({ title: 'Watch', description: 'Lean-back channel surfing over OpenVibe.Live: who is live, clips and the week\'s AI moments in one feed.', path: '/watch', body, active: '/watch' });
}
--/** The party bar: members, host, Copy link, Leave, and the optional OpenVibe.Chat room link (a link only). */
++/** The party bar: members, host, Share, Leave, and the optional OpenVibe.Chat room link (a link only). */
function partyBar({ party = {}, isHost = false, partyPath = '' } = {}) {
const chat = party.chat_url ? String(party.chat_url) : null;
const chatLink = chat
@@@ -208,10 +210,9 @@ function partyBar({ party = {}, isHost = false, partyPath = '' } = {}) {
return `<section class="sf-party" id="sf-party" data-sf-party data-sf-party-id="${esc(party.id || '')}" data-sf-party-is-host="${isHost ? '1' : '0'}" data-sf-party-channel="${esc(party.channel_key || '')}" data-sf-party-position="${esc(party.position_s == null ? 0 : party.position_s)}" data-sf-party-playing="${party.playing === false ? '0' : '1'}" data-sf-party-at="${esc(party.at || 0)}" data-sf-party-url="${esc(partyPath)}">
<div class="sf-party-bar">
<p class="sf-party-who"><span class="sf-party-dot" aria-hidden="true"></span>Watch together · <b data-sf-party-members>${esc(party.members == null ? 1 : party.members)}</b> here · host <b data-sf-party-host-name>${esc(party.host || 'the host')}</b></p>
--<div class="sf-party-actions"><button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" data-sf-party-copy data-sf-party-url="${esc(partyPath)}">Copy link</button>
++<div class="sf-party-actions">${kit.share({ title: 'Watch together on OpenVibe.Surf', url: `${layoutLib.baseUrl()}${partyPath}`, text: 'Watch the same OpenVibe.Live channel at the same moment with friends.' })}
<form method="post" action="${esc(partyPath)}/leave"><button type="submit" class="ovk-btn ovk-btn--quiet ovk-btn--small" data-sf-party-leave>Leave</button></form></div>
</div>
--<p class="sf-fine sf-party-share">Share this link: <a href="${esc(partyPath)}" data-sf-party-link>${esc(partyPath)}</a>${isHost ? ' · you are the host' : ' · the host picks the channel and the position'}</p>
<p class="sf-status" data-sf-party-status role="status"></p>
${chatLink}${chatForm}
</section>`;
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex 59d3eeb..4e2a877 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -51,6 +51,9 @@ const { assetVersion } = require('../server/assets');
assert.match(r.text, /id="sf-top"/);
assert.match(r.text, /ada/);
assert.match(r.text, /src="https:\/\/openvibe\.games\/surf\/embed\?map=surf_lagoon&amp;embed=1"/);
++ assert.match(r.text, /data-ov-share-sheet/, 'the map has one Share button');
++ assert.match(r.text, /data-url="https:\/\/openvibe\.surf\/ride\/surf_lagoon"/, 'the map\'s public address');
++ assert.match(r.text, /data-title="Surf Lagoon"/);
});
await check('/ride/:map still embeds when Games has not added its routes yet', async () => {
ddiff --git a/test/party.test.js b/test/party.test.js
iindex dd932c3..bc2c9c4 100644
---- a/test/party.test.js
++++ b/test/party.test.js
@@@ -86,7 +86,8 @@ function openEvents(t, path, cookie) {
assert.match(page.text, /data-sf-party-members>1</);
assert.match(page.text, /host <b data-sf-party-host-name>kim<\/b>/);
assert.match(page.text, /id="sf-tv"/, 'the same TV markup');
-- assert.match(page.text, /data-sf-party-copy/, 'Copy link');
++ assert.match(page.text, /data-ov-share-sheet/, 'the one Share button');
++ assert.match(page.text, new RegExp(`data-url="https://openvibe.surf${partyPath}"`), 'the party\'s own address');
assert.match(page.text, /data-sf-party-leave/, 'Leave');
assert.match(page.text, /data-sf-party-chat-form/, 'the host can paste a chat link');
assert.strictEqual(page.headers.get('cache-control'), 'private, no-store');