diff --git a/README.md b/README.md index 90f296c..d69512f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,32 @@ # Progressive Web App Kit extension for phpBB -Under development... +Progressive Web App Kit complements phpBB 4's native Web Push and web-app +manifest support. It carries forward the PWA presentation features previously +provided by the phpBB 3.3 Browser Push Notifications extension without +duplicating phpBB 4's notification delivery, subscription, or service-worker +implementation. -Allows phpBB board admins to manage web app icons and colour themes for their site. +The extension provides: + +- Per-style theme and background colours. +- Managed upload, storage, discovery, and removal of PNG app/touch icons. +- Manifest icons and Apple touch-icon links. +- Automatic discovery of existing `images/site_icons` files. + +Settings are available under **General → Client communication → PWA settings**, +next to phpBB 4's native Web Push settings. + +## Transitioning from phpBB 3.3 + +After upgrading to phpBB 4, disable Browser Push Notifications and select +**Delete data** before installing PWA Kit. Removing the old extension performs +its Web Push subscription and VAPID-key handoff to phpBB 4 core. PWA Kit will +not enable while Browser Push Notifications is still configured. + +The old PWA short name and per-style colours are removed with the old +extension's data and must be configured again. Existing files in +`images/site_icons` are retained by the old extension and PWA Kit discovers +them automatically. [](https://github.com/phpbb-extensions/pwakit/actions) [](https://codecov.io/gh/phpbb-extensions/pwakit) diff --git a/acp/pwa_acp_info.php b/acp/pwa_acp_info.php index f1fc41c..c472783 100644 --- a/acp/pwa_acp_info.php +++ b/acp/pwa_acp_info.php @@ -21,7 +21,8 @@ public function module(): array 'settings' => [ 'title' => 'ACP_PWA_KIT_SETTINGS', 'auth' => 'ext_phpbb/pwakit && acl_a_board', - 'cat' => ['ACP_PWA_KIT_TITLE'] + 'cat' => ['ACP_CLIENT_COMMUNICATION'], + 'after' => 'ACP_WEBPUSH_SETTINGS', ], ], ]; diff --git a/styles/all/theme/acp.css b/adm/style/acp.css similarity index 91% rename from styles/all/theme/acp.css rename to adm/style/acp.css index 61d5f5f..26cf666 100644 --- a/styles/all/theme/acp.css +++ b/adm/style/acp.css @@ -91,3 +91,13 @@ input[type="color"]::-moz-color-swatch { display: inline-block; width: 120px; } + +.pwa-colour-preview { + position: fixed; + left: -10000px; + top: 0; + visibility: hidden; + width: 1280px; + height: 800px; + border: 0; +} diff --git a/adm/style/acp.js b/adm/style/acp.js new file mode 100644 index 0000000..1237da6 --- /dev/null +++ b/adm/style/acp.js @@ -0,0 +1,151 @@ +document.addEventListener('DOMContentLoaded', () => { + 'use strict'; + + const HEX_REGEX = /^#([A-Fa-f0-9]{6})$/; + const DETECTION_TIMEOUT = 30000; + + const colorPickers = document.querySelectorAll('input[type="color"]'); + + colorPickers.forEach(colorPicker => { + const colorText = colorPicker.previousElementSibling; + + if (!colorText || colorText.type !== 'text') { + return; + } + + const syncColors = (source, target) => { + const value = source.value.trim(); + target.value = HEX_REGEX.test(value) ? value : colorText.placeholder; + }; + + const handleInput = ({ target }) => { + if (target === colorPicker) { + colorText.value = target.value; + } else { + syncColors(colorText, colorPicker); + } + }; + + colorPicker.addEventListener('input', handleInput); + colorText.addEventListener('input', handleInput); + colorText.addEventListener('blur', () => { + if (!colorText.value.trim()) { + colorPicker.value = colorText.placeholder; + } + }); + + syncColors(colorText, colorPicker); + }); + + // Read an element's computed background colour as RGBA values. + const readColor = (view, element) => { + const value = view.getComputedStyle(element).backgroundColor; + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + + canvas.width = 1; + canvas.height = 1; + context.clearRect(0, 0, 1, 1); + context.fillStyle = value; + context.fillRect(0, 0, 1, 1); + + return Array.from(context.getImageData(0, 0, 1, 1).data); + }; + + // Blend a foreground colour over a background colour. + const compositeColor = (foreground, background) => { + const alpha = foreground[3] / 255; + + return [ + Math.round(foreground[0] * alpha + background[0] * (1 - alpha)), + Math.round(foreground[1] * alpha + background[1] * (1 - alpha)), + Math.round(foreground[2] * alpha + background[2] * (1 - alpha)), + 255, + ]; + }; + + // Resolve the visible page colour from the preview's HTML and body backgrounds. + const detectPageColor = iframe => { + const previewDocument = iframe.contentDocument; + const previewWindow = iframe.contentWindow; + const htmlColor = readColor(previewWindow, previewDocument.documentElement); + const bodyColor = readColor(previewWindow, previewDocument.body); + + if (htmlColor[3] === 0 && bodyColor[3] === 0) { + return null; + } + + const canvasColor = compositeColor(htmlColor, [ 255, 255, 255, 255 ]); + return compositeColor(bodyColor, canvasColor); + }; + + // Convert RGB values to a six-digit hexadecimal colour. + const toHex = color => '#' + color.slice(0, 3) + .map(channel => channel.toString(16).padStart(2, '0')) + .join(''); + + // Update a style's text field and synchronize its colour picker. + const setColor = (styleId, name, value) => { + const colorText = document.getElementById(`pwa_${name}_color_${styleId}`); + + colorText.value = value; + colorText.dispatchEvent(new Event('input', { bubbles: true })); + }; + + // Attach colour detection to each installed style's button. + document.querySelectorAll('.pwa-detect-colours').forEach(button => { + button.addEventListener('click', () => { + const originalLabel = button.value; + const status = button.closest('dd').querySelector('.pwa-detect-status'); + const iframe = document.createElement('iframe'); + const previewUrl = new URL(button.dataset.previewUrl, window.location.href); + let finished = false; + + // Remove the preview and restore the detector button after completion. + const finish = error => { + if (finished) { + return; + } + + finished = true; + clearTimeout(timeout); + iframe.remove(); + button.disabled = false; + button.value = originalLabel; + status.textContent = error ? button.dataset.errorMessage : ''; + }; + + const timeout = setTimeout(() => finish(true), DETECTION_TIMEOUT); + + button.disabled = true; + button.value = button.dataset.detectingLabel; + status.textContent = ''; + status.title = ''; + iframe.className = 'pwa-colour-preview'; + iframe.setAttribute('aria-hidden', 'true'); + iframe.setAttribute('sandbox', 'allow-same-origin'); + iframe.addEventListener('load', () => { + try { + const themeColor = detectPageColor(iframe); + + if (!themeColor) { + throw new Error(button.dataset.noBackgroundMessage); + } + + const pageColor = toHex(themeColor); + + setColor(button.dataset.styleId, 'theme', pageColor); + setColor(button.dataset.styleId, 'bg', pageColor); + finish(false); + } catch (error) { + status.title = error.message; + finish(true); + } + }); + iframe.addEventListener('error', () => finish(true)); + previewUrl.searchParams.set('style', button.dataset.styleId); + iframe.src = previewUrl.href; + document.body.appendChild(iframe); + }); + }); +}); diff --git a/adm/style/acp_pwakit.html b/adm/style/acp_pwakit.html index 896ae29..0dd6a73 100644 --- a/adm/style/acp_pwakit.html +++ b/adm/style/acp_pwakit.html @@ -5,7 +5,7 @@
{{ lang('ACP_PWA_KIT_EXPLAIN', PWA_IMAGES_DIR) }}
+{{ lang('ACP_PWA_EXPLAIN') }}
{% if S_ERROR %}