Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

[![Build Status](https://github.com/phpbb-extensions/pwakit/workflows/Tests/badge.svg)](https://github.com/phpbb-extensions/pwakit/actions)
[![codecov](https://codecov.io/gh/phpbb-extensions/pwakit/graph/badge.svg?token=34V2MQSY3H)](https://codecov.io/gh/phpbb-extensions/pwakit)
Expand Down
3 changes: 2 additions & 1 deletion acp/pwa_acp_info.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
],
];
Expand Down
10 changes: 10 additions & 0 deletions styles/all/theme/acp.css → adm/style/acp.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
151 changes: 151 additions & 0 deletions adm/style/acp.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
32 changes: 21 additions & 11 deletions adm/style/acp_pwakit.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<a id="maincontent"></a>

<h1>{{ lang('ACP_PWA_KIT_TITLE') }}</h1>
<p>{{ lang('ACP_PWA_KIT_EXPLAIN', PWA_IMAGES_DIR) }}</p>
<p>{{ lang('ACP_PWA_EXPLAIN') }}</p>

{% if S_ERROR %}
<div class="errorbox">
Expand All @@ -16,21 +16,21 @@ <h3>{{ lang('WARNING') }}</h3>

<form id="acp_pwakit" method="post" action="{{ U_ACTION }}" enctype="multipart/form-data">
<fieldset>
<legend>{{ lang('ACP_PWA_KIT_LEGEND_IDENTITY') }}</legend>
<legend>{{ lang('ACP_PWA_LEGEND_IDENTITY') }}</legend>
<dl>
<dt><label>{{ lang('SITE_NAME') ~ lang('COLON') }}</label><br><span>{{ lang('ACP_PWA_KIT_SITE_NAME_EXPLAIN', U_BOARD_SETTINGS) }}</span></dt>
<dt><label>{{ lang('SITE_NAME') ~ lang('COLON') }}</label><br><span>{{ lang('ACP_PWA_SITE_NAME_EXPLAIN', U_BOARD_SETTINGS) }}</span></dt>
<dd>{{ SITE_NAME }}</dd>
</dl>
<dl>
<dt><label>{{ lang('SITE_NAME_SHORT') ~ lang('COLON') }}</label><br><span>{{ lang('ACP_PWA_KIT_SHORT_NAME_EXPLAIN', U_BOARD_SETTINGS) }}</span></dt>
<dt><label>{{ lang('SITE_NAME_SHORT') ~ lang('COLON') }}</label><br><span>{{ lang('ACP_PWA_SHORT_NAME_EXPLAIN', U_BOARD_SETTINGS) }}</span></dt>
<dd>{{ SITE_NAME_SHORT }}</dd>
</dl>
</fieldset>

<h1>{{ lang('ACP_PWA_COLORS') }}</h1>
<p>{{ lang('ACP_PWA_COLORS_EXPLAIN') }}</p>
<fieldset>
<legend>{{ lang('ACP_PWA_KIT_LEGEND_PRESENTS') }}</legend>
<legend>{{ lang('ACP_PWA_LEGEND_PRESENTS') }}</legend>
<dl>
<dt><label>{{ lang('ACP_STYLES') ~ lang('COLON') }}</label></dt>
<dd>
Expand All @@ -51,6 +51,16 @@ <h1>{{ lang('ACP_PWA_COLORS') }}</h1>
<input type="text" id="pwa_bg_color_{{ style.style_id }}" name="pwa_bg_color_{{ style.style_id }}" size="8" value="{{ style.pwa_bg_color }}" placeholder="{{ default_color }}">
<input type="color" id="pwa_bg_color_picker_{{ style.style_id }}" aria-label="{{ lang('ACP_PWA_BG_COLOR') }}" value="{{ style.pwa_bg_color|default(default_color) }}">
</span>
<input
type="button"
class="button2 pwa-detect-colours"
value="{{ lang('ACP_PWA_DETECT_COLOURS')|e('html_attr') }}"
data-style-id="{{ style.style_id|e('html_attr') }}"
data-preview-url="{{ U_INDEX }}"
data-detecting-label="{{ lang('ACP_PWA_DETECTING_COLOURS')|e('html_attr') }}"
data-no-background-message="{{ lang('ACP_PWA_DETECT_COLOURS_NO_BG')|e('html_attr') }}"
data-error-message="{{ lang('ACP_PWA_DETECT_COLOURS_FAILED')|e('html_attr') }}">
<span class="pwa-detect-status error" role="status"></span>
</dd>
</dl>
{% endfor %}
Expand All @@ -65,20 +75,20 @@ <h1>{{ lang('ACP_PWA_COLORS') }}</h1>
{{ S_FORM_TOKEN }}
</fieldset>

<h1>{{ lang('ACP_PWA_KIT_APP_ICONS') }}</h1>
<p>{{ lang('ACP_PWA_KIT_APP_ICONS_EXPLAIN') }}</p>
<h1>{{ lang('ACP_PWA_APP_ICONS') }}</h1>
<p>{{ lang('ACP_PWA_APP_ICONS_EXPLAIN') }}</p>
<fieldset>
<legend>{{ lang('ACP_PWA_KIT_LEGEND_ICONS') }}</legend>
<legend>{{ lang('ACP_PWA_LEGEND_ICONS') }}</legend>
<dl>
<dt><label for="pwa_upload">{{ lang('ACP_PWA_IMG_UPLOAD') ~ lang('COLON') }}</label><br><span>{{ lang('ACP_PWA_IMG_UPLOAD_EXPLAIN', PWA_IMAGES_DIR, U_STORAGE_SETTINGS) }}</span></dt>
<dd>
<input type="file" accept="image/*" class="inputbox autowidth" id="pwa_upload" name="pwa_upload">
<input type="file" accept="image/png,.png" class="inputbox autowidth" id="pwa_upload" name="pwa_upload">
<button class="button2 pwakit-button" id="upload" name="upload" value="{{ lang('ACP_PWA_IMG_UPLOAD_BTN') }}">{{ Icon('font', 'upload', lang('ACP_PWA_IMG_UPLOAD_BTN')) }}</button>
<button class="button2 pwakit-button" id="resync" name="resync" value="{{ lang('ACP_PWA_IMG_RESYNC_BTN') }}">{{ Icon('font', 'sync', lang('ACP_PWA_IMG_RESYNC_BTN')) }}</button>
</dd>
</dl>
<dl>
<dt><label>{{ lang('ACP_PWA_KIT_ICONS') ~ lang('COLON') }}</label><br><span>{{ lang('ACP_PWA_KIT_ICONS_EXPLAIN') }}</span></dt>
<dt><label>{{ lang('ACP_PWA_ICONS') ~ lang('COLON') }}</label><br><span>{{ lang('ACP_PWA_ICONS_EXPLAIN') }}</span></dt>
<dd>
{% for icon in PWA_KIT_ICONS %}
{% set iconName = icon.src|split('/')|last %}
Expand All @@ -95,7 +105,7 @@ <h1>{{ lang('ACP_PWA_KIT_APP_ICONS') }}</h1>
{{ iconName }}<br>{{ icon.sizes }}
</p>
{% else %}
{{ lang('ACP_PWA_KIT_NO_ICONS') }}
{{ lang('ACP_PWA_NO_ICONS') }}
{% endfor %}
</dd>
</dl>
Expand Down
12 changes: 7 additions & 5 deletions controller/admin_controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,13 @@ protected function execute_action(string $action): void
*/
protected function display_settings(): void
{
$icons = $this->helper->get_icons($this->phpbb_root_path);

$this->template->assign_vars([
'SITE_NAME' => $this->config->offsetGet('sitename'),
'SITE_NAME_SHORT' => $this->config->offsetGet('sitename_short') ?: $this->trim_name($this->config->offsetGet('sitename'), 0, 12),
'PWA_IMAGES_DIR' => $this->helper->get_storage_path(),
'PWA_KIT_ICONS' => $this->helper->get_icons($this->phpbb_root_path),
'PWA_KIT_ICONS' => $icons,
'STYLES' => $this->get_styles(),
'U_BOARD_SETTINGS' => append_sid("{$this->phpbb_admin_path}index.$this->php_ext", 'i=acp_board&amp;mode=settings'),
'U_STORAGE_SETTINGS'=> append_sid("{$this->phpbb_admin_path}index.$this->php_ext", 'i=acp_storage&amp;mode=settings'),
Expand All @@ -210,22 +212,22 @@ protected function save_settings(): void
foreach ($styles as $row)
{
$style_id = $row['style_id'];
$pwa_bg_color = $this->request->variable('pwa_bg_color_' . $style_id, '');
$pwa_theme_color = $this->request->variable('pwa_theme_color_' . $style_id, '');
$pwa_bg_color = trim($this->request->variable('pwa_bg_color_' . $style_id, ''));
$pwa_theme_color = trim($this->request->variable('pwa_theme_color_' . $style_id, ''));

$updates[$style_id] = [
'pwa_bg_color' => $this->validate_hex_color($pwa_bg_color) ? $pwa_bg_color : $row['pwa_bg_color'],
'pwa_theme_color' => $this->validate_hex_color($pwa_theme_color) ? $pwa_theme_color : $row['pwa_theme_color'],
];
}

$this->set_styles($updates);

if ($this->has_errors())
{
return;
}

$this->set_styles($updates);

$this->success('CONFIG_UPDATED');
}

Expand Down
Loading
Loading