Add FCM device registration and Gitea bug reporter
This commit is contained in:
@@ -592,3 +592,20 @@ h1, .page-title h1 {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.native-push-panel { padding:16px; margin:16px 0; border:1px solid var(--border); border-radius:10px; background:var(--surface); }
|
||||
.native-push-panel .button { margin:4px 8px 4px 0; }
|
||||
.bug-report-section { max-width:820px; margin:0 auto; }
|
||||
.bug-report-form { display:grid; gap:10px; max-width:none; }
|
||||
.bug-report-form input:not([type="checkbox"]):not([type="hidden"]),
|
||||
.bug-report-form textarea, .bug-report-form select { width:100%; margin:0; padding:12px; border:1px solid var(--border); border-radius:8px; background:#101010; color:var(--text); font:inherit; }
|
||||
.bug-report-form textarea { resize:vertical; }
|
||||
.bug-report-options { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
|
||||
.bug-report-options label { display:grid; gap:8px; }
|
||||
.bug-report-form .bug-context { display:flex; align-items:flex-start; gap:10px; margin-top:14px; }
|
||||
.bug-context input { width:auto; margin-top:3px; }
|
||||
.bug-help { color:var(--muted); font-size:.9rem; margin:0 0 8px; overflow-wrap:anywhere; }
|
||||
.bug-error { color:#fca5a5; }
|
||||
.bug-success { color:#bbf7d0; }
|
||||
.bug-report-form button:disabled { opacity:.6; cursor:wait; }
|
||||
@media(max-width:600px) { .bug-report-options { grid-template-columns:1fr; } }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
(() => {
|
||||
const form = document.querySelector('.bug-report-form');
|
||||
if (!form) return;
|
||||
const cap = window.Capacitor;
|
||||
if (cap?.getPlatform() === 'android') {
|
||||
document.getElementById('bug-platform').value = 'Android';
|
||||
cap.Plugins.MetalCircleDevice?.getInfo().then(info => {
|
||||
document.getElementById('bug-app-version').value = info.appVersion || '';
|
||||
}).catch(() => {});
|
||||
}
|
||||
form.addEventListener('submit', () => { form.querySelector('button[type="submit"]').disabled = true; });
|
||||
window.addEventListener('pageshow', event => { if (event.persisted) location.reload(); });
|
||||
})();
|
||||
@@ -0,0 +1,144 @@
|
||||
/* Android-only bridge. FCM tokens stay in memory and are never logged or persisted in JS. */
|
||||
(() => {
|
||||
const config = document.getElementById('native-push-config');
|
||||
const cap = window.Capacitor;
|
||||
if (!config || !cap || cap.getPlatform() !== 'android') return;
|
||||
const push = cap.Plugins.PushNotifications;
|
||||
const device = cap.Plugins.MetalCircleDevice;
|
||||
if (!push || !device) return;
|
||||
const texts = JSON.parse(config.textContent);
|
||||
let binding = '';
|
||||
let info;
|
||||
let stopped = false;
|
||||
let running = false;
|
||||
let ready = false;
|
||||
let sendQueue = Promise.resolve();
|
||||
let panel;
|
||||
|
||||
function notice(message, offerPermission = false) {
|
||||
if (!panel) {
|
||||
panel = document.createElement('section');
|
||||
panel.className = 'native-push-panel';
|
||||
(document.querySelector('main') || document.querySelector('.container') || document.body).prepend(panel);
|
||||
}
|
||||
panel.replaceChildren();
|
||||
const label = document.createElement('p');
|
||||
label.textContent = message;
|
||||
label.setAttribute('role', 'status');
|
||||
panel.append(label);
|
||||
if (offerPermission) {
|
||||
const allow = document.createElement('button');
|
||||
allow.type = 'button';
|
||||
allow.className = 'button';
|
||||
allow.textContent = texts.enable;
|
||||
allow.onclick = async () => {
|
||||
allow.disabled = true;
|
||||
try {
|
||||
await push.requestPermissions();
|
||||
localStorage.setItem('metalcircle_push_prompt', 'seen');
|
||||
await synchronize();
|
||||
} catch (_) { notice(texts.failed); }
|
||||
};
|
||||
const later = document.createElement('button');
|
||||
later.type = 'button';
|
||||
later.className = 'button button-secondary';
|
||||
later.textContent = texts.later;
|
||||
later.onclick = () => {
|
||||
localStorage.setItem('metalcircle_push_prompt', 'seen');
|
||||
panel.remove(); panel = null;
|
||||
};
|
||||
panel.append(allow, later);
|
||||
}
|
||||
if (ready && info?.debug && location.pathname === '/profile') {
|
||||
const debug = document.createElement('button');
|
||||
debug.type = 'button';
|
||||
debug.className = 'button button-secondary';
|
||||
debug.textContent = texts.debug;
|
||||
debug.onclick = () => device.showDebugToken({language: document.documentElement.lang}).catch(() => notice(texts.failed));
|
||||
panel.append(debug);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendToken(token) {
|
||||
if (stopped || !binding || !info) return;
|
||||
const expected = binding;
|
||||
const response = await fetch('/api/push/devices', {
|
||||
method: 'POST', credentials: 'same-origin', redirect: 'error',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({device_id: info.deviceId, token, platform: 'android',
|
||||
app_version: info.appVersion, session_tag: expected})
|
||||
});
|
||||
if (stopped || binding !== expected) return;
|
||||
ready = response.ok;
|
||||
if (!response.ok) { notice(texts.failed); return; }
|
||||
if (location.pathname === '/profile') notice(texts.ready);
|
||||
else if (panel) { panel.remove(); panel = null; }
|
||||
}
|
||||
|
||||
async function synchronize() {
|
||||
if (stopped || running) return;
|
||||
running = true;
|
||||
try {
|
||||
const response = await fetch('/api/push/session', {credentials: 'same-origin', cache: 'no-store', redirect: 'error'});
|
||||
if (!response.ok) return;
|
||||
const session = await response.json();
|
||||
info = await device.getInfo();
|
||||
if (stopped) return;
|
||||
if (!session.authenticated) {
|
||||
binding = ''; ready = false;
|
||||
await device.prepareSession({binding: ''});
|
||||
return;
|
||||
}
|
||||
const permissions = await push.checkPermissions();
|
||||
if (permissions.receive !== 'granted') {
|
||||
binding = ''; ready = false;
|
||||
await fetch('/api/push/devices/' + encodeURIComponent(info.deviceId), {method: 'DELETE', credentials: 'same-origin'});
|
||||
await device.prepareSession({binding: ''});
|
||||
const prompt = permissions.receive === 'prompt' || permissions.receive === 'prompt-with-rationale';
|
||||
if (location.pathname === '/profile' || (!localStorage.getItem('metalcircle_push_prompt') && prompt)) {
|
||||
notice(prompt ? texts.permission : texts.denied, prompt);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Invalidate the previous account/session's FCM token before acquiring a new one.
|
||||
binding = '';
|
||||
await device.prepareSession({binding: session.session_tag});
|
||||
if (stopped) return;
|
||||
binding = session.session_tag;
|
||||
await push.register();
|
||||
} catch (_) {
|
||||
if (!stopped && location.pathname === '/profile') notice(texts.failed);
|
||||
} finally { running = false; }
|
||||
}
|
||||
|
||||
document.addEventListener('submit', async event => {
|
||||
const form = event.target;
|
||||
if (!(form instanceof HTMLFormElement) || !['/logout', '/profile/delete'].includes(new URL(form.action).pathname)) return;
|
||||
event.preventDefault();
|
||||
stopped = true; binding = ''; ready = false;
|
||||
try {
|
||||
await sendQueue;
|
||||
await device.prepareSession({binding: ''});
|
||||
} catch (_) { /* Server-side session deletion still revokes every bound device. */ }
|
||||
form.submit();
|
||||
}, true);
|
||||
|
||||
Promise.all([
|
||||
push.addListener('registration', event => {
|
||||
sendQueue = sendQueue.then(() => sendToken(event.value)).catch(() => {
|
||||
if (!stopped) notice(texts.failed);
|
||||
});
|
||||
}),
|
||||
push.addListener('registrationError', () => { if (!stopped) notice(texts.failed); }),
|
||||
push.addListener('pushNotificationActionPerformed', () => {
|
||||
// Do not navigate to arbitrary URLs supplied by a notification payload.
|
||||
location.assign('/');
|
||||
})
|
||||
]).then(() => {
|
||||
synchronize();
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) synchronize(); });
|
||||
document.addEventListener('resume', synchronize);
|
||||
window.addEventListener('online', synchronize);
|
||||
window.addEventListener('pageshow', synchronize);
|
||||
}).catch(() => { /* The website remains usable when the bridge is unavailable. */ });
|
||||
})();
|
||||
Reference in New Issue
Block a user