openvibers/openvibe.live Commits
After a follow, the channel offers go-live alerts on this device (browser push), one press away (#332)
Changes
@@@ -1510,3 +1510,9 @@.ch-bot-bind-text code { font-size: 0.85em; }.ch-bot-bind-note { margin: 0 0 10px; font-size: 0.85rem; color: var(--text-secondary); }.ch-bot-bind-actions { display: flex; gap: 8px; flex-wrap: wrap; }++++/* After a follow: the go-live alerts offer (app-channel.js offerLiveAlerts), a toast with its own two buttons. */++.toast.toast-offer { flex-direction: column; align-items: stretch; gap: 8px; cursor: default; max-width: min(320px, calc(100vw - 24px)); }++.toast-offer-text { margin: 0; }++.toast-offer-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }++@media (max-width: 768px) { .toast.toast-offer { max-width: none; } }@@@ -3332,9 +3332,71 @@ async function toggleChannelFollow(username) {if (el) el.textContent = `${data.count || 0} followers`;});toast(data.following ? 'Followed!' : 'Unfollowed', 'info');++ if (data.following) offerLiveAlerts(username);} catch (e) { toast(e.message, 'error'); }}++/**++ * Right after a follow, offer go-live alerts on this device. Network pushes every STREAM_LIVE notification to the++ * devices that turned push on, but on 2026-10-23 only 2 of 426 people had: the switch sat inside the bell's panel.++ * Offered only where push works and is neither on nor blocked, and not for 30 days after "Not now". The browser's++ * permission prompt opens from the button (a user gesture), never by itself.++ */++const LIVE_ALERTS_DECLINED_KEY = 'ov_live_alerts_declined_at';++async function offerLiveAlerts(name) {++ const n = window.OpenVibeNotifications;++ const box = document.getElementById('toast-container');++ if (!box || !n || typeof n.pushStatus !== 'function' || typeof n.enablePush !== 'function') return;++ if (document.getElementById('live-alerts-offer')) return;++ let st;++ try { st = await n.pushStatus(); } catch { return; }++ if (!st || !st.supported || st.subscribed || st.permission === 'denied') return;++ try {++ if (Date.now() - (Number(localStorage.getItem(LIVE_ALERTS_DECLINED_KEY)) || 0) < 30 * 24 * 3600 * 1000) return;++ } catch { /* no storage: offer it */ }++++ const card = document.createElement('div');++ card.id = 'live-alerts-offer';++ card.className = 'toast info toast-offer';++ card.setAttribute('role', 'group');++ card.setAttribute('aria-label', 'Go-live alerts');++ const text = document.createElement('p');++ text.className = 'toast-offer-text';++ text.textContent = `Turn on alerts on this device? You'll get one when ${name} goes live, even with this tab closed.`;++ const actions = document.createElement('div');++ actions.className = 'toast-offer-actions';++ const later = document.createElement('button');++ later.type = 'button';++ later.className = 'btn btn-small';++ later.textContent = 'Not now';++ const on = document.createElement('button');++ on.type = 'button';++ on.className = 'btn btn-primary btn-small';++ on.textContent = 'Turn on alerts';++ actions.append(later, on);++ card.append(text, actions);++ later.addEventListener('click', () => {++ try { localStorage.setItem(LIVE_ALERTS_DECLINED_KEY, String(Date.now())); } catch { /* */ }++ card.remove();++ });++ on.addEventListener('click', async () => {++ on.disabled = true;++ later.disabled = true;++ try {++ await n.enablePush();++ text.textContent = `Alerts are on for this device. You'll hear when ${name} or anyone else you follow goes live.`;++ actions.remove();++ setTimeout(() => card.remove(), 6000);++ } catch (err) {++ text.textContent = (err && err.message) || 'Alerts could not be turned on in this browser.';++ on.remove();++ later.disabled = false;++ later.textContent = 'Close';++ }++ });++ box.appendChild(card);++}++async function banChannelUser(userId, username) {if (!userId || !username) return;const reason = prompt(`⚠️ GLOBAL BAN: Ban ${username} from the entire site?\n\nEnter reason:`);
@@ -1510,3 +1510,9 @@
@@ -1510,3 +1510,9 @@
.ch-bot-bind-text code { font-size: 0.85em; }
.ch-bot-bind-text code { font-size: 0.85em; }
.ch-bot-bind-note { margin: 0 0 10px; font-size: 0.85rem; color: var(--text-secondary); }
.ch-bot-bind-note { margin: 0 0 10px; font-size: 0.85rem; color: var(--text-secondary); }
.ch-bot-bind-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.ch-bot-bind-actions { display: flex; gap: 8px; flex-wrap: wrap; }
+
+/* After a follow: the go-live alerts offer (app-channel.js offerLiveAlerts), a toast with its own two buttons. */
+.toast.toast-offer { flex-direction: column; align-items: stretch; gap: 8px; cursor: default; max-width: min(320px, calc(100vw - 24px)); }
+.toast-offer-text { margin: 0; }
+.toast-offer-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
+@media (max-width: 768px) { .toast.toast-offer { max-width: none; } }
@@ -3332,9 +3332,71 @@ async function toggleChannelFollow(username) {
@@ -3332,9 +3332,71 @@ async function toggleChannelFollow(username) {
if (el) el.textContent = `${data.count || 0} followers`;
if (el) el.textContent = `${data.count || 0} followers`;
});
});
toast(data.following ? 'Followed!' : 'Unfollowed', 'info');
toast(data.following ? 'Followed!' : 'Unfollowed', 'info');
+ if (data.following) offerLiveAlerts(username);
} catch (e) { toast(e.message, 'error'); }
} catch (e) { toast(e.message, 'error'); }
}
}
+/**
+ * Right after a follow, offer go-live alerts on this device. Network pushes every STREAM_LIVE notification to the
+ * devices that turned push on, but on 2026-10-23 only 2 of 426 people had: the switch sat inside the bell's panel.
+ * Offered only where push works and is neither on nor blocked, and not for 30 days after "Not now". The browser's
+ * permission prompt opens from the button (a user gesture), never by itself.
+ */
+const LIVE_ALERTS_DECLINED_KEY = 'ov_live_alerts_declined_at';
+async function offerLiveAlerts(name) {
+ const n = window.OpenVibeNotifications;
+ const box = document.getElementById('toast-container');
+ if (!box || !n || typeof n.pushStatus !== 'function' || typeof n.enablePush !== 'function') return;
+ if (document.getElementById('live-alerts-offer')) return;
+ let st;
+ try { st = await n.pushStatus(); } catch { return; }
+ if (!st || !st.supported || st.subscribed || st.permission === 'denied') return;
+ try {
+ if (Date.now() - (Number(localStorage.getItem(LIVE_ALERTS_DECLINED_KEY)) || 0) < 30 * 24 * 3600 * 1000) return;
+ } catch { /* no storage: offer it */ }
+
+ const card = document.createElement('div');
+ card.id = 'live-alerts-offer';
+ card.className = 'toast info toast-offer';
+ card.setAttribute('role', 'group');
+ card.setAttribute('aria-label', 'Go-live alerts');
+ const text = document.createElement('p');
+ text.className = 'toast-offer-text';
+ text.textContent = `Turn on alerts on this device? You'll get one when ${name} goes live, even with this tab closed.`;
+ const actions = document.createElement('div');
+ actions.className = 'toast-offer-actions';
+ const later = document.createElement('button');
+ later.type = 'button';
+ later.className = 'btn btn-small';
+ later.textContent = 'Not now';
+ const on = document.createElement('button');
+ on.type = 'button';
+ on.className = 'btn btn-primary btn-small';
+ on.textContent = 'Turn on alerts';
+ actions.append(later, on);
+ card.append(text, actions);
+ later.addEventListener('click', () => {
+ try { localStorage.setItem(LIVE_ALERTS_DECLINED_KEY, String(Date.now())); } catch { /* */ }
+ card.remove();
+ });
+ on.addEventListener('click', async () => {
+ on.disabled = true;
+ later.disabled = true;
+ try {
+ await n.enablePush();
+ text.textContent = `Alerts are on for this device. You'll hear when ${name} or anyone else you follow goes live.`;
+ actions.remove();
+ setTimeout(() => card.remove(), 6000);
+ } catch (err) {
+ text.textContent = (err && err.message) || 'Alerts could not be turned on in this browser.';
+ on.remove();
+ later.disabled = false;
+ later.textContent = 'Close';
+ }
+ });
+ box.appendChild(card);
+}
+
async function banChannelUser(userId, username) {
async function banChannelUser(userId, username) {
if (!userId || !username) return;
if (!userId || !username) return;
const reason = prompt(`⚠️ GLOBAL BAN: Ban ${username} from the entire site?\n\nEnter reason:`);
const reason = prompt(`⚠️ GLOBAL BAN: Ban ${username} from the entire site?\n\nEnter reason:`);