diff options
author | Matthew Somerville <matthew@mysociety.org> | 2020-02-14 12:55:15 +0000 |
---|---|---|
committer | Matthew Somerville <matthew@mysociety.org> | 2020-02-14 12:55:15 +0000 |
commit | a5373a4ce9e966f70cd5edf0334c27781d7a2f04 (patch) | |
tree | 9bf59ec3fd7b9de0194dc8e997e57e18f787629c | |
parent | 2530538ca6e36b659a63cc3b67099972fe0116af (diff) | |
parent | f0edab7ee84e4358c279a5a8ec37dbb0ae8f1b3d (diff) |
Merge branch 'pwa'
26 files changed, 519 insertions, 297 deletions
diff --git a/.gitignore b/.gitignore index 9c6297b3d..974f82187 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,8 @@ FixMyBarangay.po # Photo upload cache /web/photo +# And theme upload +/web/theme # Installation /local diff --git a/CHANGELOG.md b/CHANGELOG.md index c4f7bde7a..38cafc2ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - Display map inline with duplicate suggestions on mobile. #2668 - Improved try again process on mobile. #2863 - Improve messaging/display of private reports. + - Add a web manifest and service worker. #2220 - Admin improvements: - Add new roles system, to group permissions and apply to users. #2483 - Contact form emails now include user admin links. diff --git a/bin/update-schema b/bin/update-schema index 807e144eb..7941bc542 100755 --- a/bin/update-schema +++ b/bin/update-schema @@ -212,6 +212,7 @@ else { # (assuming schema change files are never half-applied, which should be the case) sub get_db_version { return 'EMPTY' if ! table_exists('problem'); + return '0071' if table_exists('manifest_theme'); return '0070' if column_like('alert_type', "ref='new_problems'", 'head_title', '{{SITE_NAME}}'); return '0069' if constraint_contains('admin_log_object_type_check', 'template'); return '0068' if column_exists('users', 'oidc_ids'); diff --git a/conf/general.yml-docker b/conf/general.yml-docker index c73bd3657..33dcad67b 100644 --- a/conf/general.yml-docker +++ b/conf/general.yml-docker @@ -52,13 +52,10 @@ STAGING_SITE: 1 # this to 1 if you want a staging site to route reports as normal. # - skip_checks: Manual testing of multiple cobrands can be made easier by # skipping some checks they have in them, if this variable is set. -# - enable_appcache: Whether the appcache should be active. NB: Only affects -# superuser sessions. STAGING_FLAGS: send_reports: 0 skip_checks: 0 hide_staging_banner: 0 - enable_appcache: 0 # What to use as front page/alert example places placeholder # Defaults to High Street, Main Street diff --git a/conf/general.yml-example b/conf/general.yml-example index 9bf6d1d05..8e8d0f76a 100644 --- a/conf/general.yml-example +++ b/conf/general.yml-example @@ -52,13 +52,10 @@ STAGING_SITE: 1 # this to 1 if you want a staging site to route reports as normal. # - skip_checks: Manual testing of multiple cobrands can be made easier by # skipping some checks they have in them, if this variable is set. -# - enable_appcache: Whether the appcache should be active. NB: Only affects -# superuser sessions. STAGING_FLAGS: send_reports: 0 skip_checks: 0 hide_staging_banner: 0 - enable_appcache: 0 # What to use as front page/alert example places placeholder # Defaults to High Street, Main Street diff --git a/db/downgrade_0071---0070.sql b/db/downgrade_0071---0070.sql new file mode 100644 index 000000000..ebf206624 --- /dev/null +++ b/db/downgrade_0071---0070.sql @@ -0,0 +1,18 @@ +BEGIN; + +DROP TABLE manifest_theme; + +ALTER TABLE admin_log DROP CONSTRAINT admin_log_object_type_check; + +ALTER TABLE admin_log ADD CONSTRAINT admin_log_object_type_check CHECK ( + object_type = 'problem' + OR object_type = 'update' + OR object_type = 'user' + OR object_type = 'moderation' + OR object_type = 'template' + OR object_type = 'body' + OR object_type = 'category' + OR object_type = 'role' +); + +COMMIT; diff --git a/db/schema.sql b/db/schema.sql index cf2914467..0e38ad862 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -449,6 +449,7 @@ create table admin_log ( or object_type = 'body' or object_type = 'category' or object_type = 'role' + or object_type = 'manifesttheme' ), object_id integer not null, action text not null, @@ -562,3 +563,13 @@ CREATE TABLE state ( type text not null check (type = 'open' OR type = 'closed' OR type = 'fixed'), name text not null unique ); + +CREATE TABLE manifest_theme ( + id serial not null primary key, + cobrand text not null unique, + name text not null, + short_name text not null, + background_colour text, + theme_colour text, + images text ARRAY +); diff --git a/db/schema_0071-add-manifest-theme.sql b/db/schema_0071-add-manifest-theme.sql new file mode 100644 index 000000000..308d345f5 --- /dev/null +++ b/db/schema_0071-add-manifest-theme.sql @@ -0,0 +1,28 @@ +BEGIN; + +CREATE TABLE manifest_theme ( + id serial not null primary key, + cobrand text not null unique, + name text not null, + short_name text not null, + background_colour text, + theme_colour text, + images text ARRAY +); + +ALTER TABLE admin_log DROP CONSTRAINT admin_log_object_type_check; + +ALTER TABLE admin_log ADD CONSTRAINT admin_log_object_type_check CHECK ( + object_type = 'problem' + OR object_type = 'update' + OR object_type = 'user' + OR object_type = 'moderation' + OR object_type = 'template' + OR object_type = 'body' + OR object_type = 'category' + OR object_type = 'role' + OR object_type = 'manifesttheme' +); + + +COMMIT; diff --git a/docs/customising/config.md b/docs/customising/config.md index ff994a25b..c5ac015b7 100644 --- a/docs/customising/config.md +++ b/docs/customising/config.md @@ -348,13 +348,12 @@ STAGING_FLAGS: send_reports: 0 skip_checks: 1 hide_staging_banner: 1 - enable_appcache: 0 </pre> </div> <p> Any reports created will now be sent to the email of the reporter - and <em>not</em> the body's; any location checks are skipped, and - we won't ever use appcache. Great for testing! + and <em>not</em> the body's; and any location checks are skipped. + Great for testing! </p> </dd> diff --git a/perllib/FixMyStreet/App/Controller/Offline.pm b/perllib/FixMyStreet/App/Controller/Offline.pm index f5d895318..57cbe201c 100644 --- a/perllib/FixMyStreet/App/Controller/Offline.pm +++ b/perllib/FixMyStreet/App/Controller/Offline.pm @@ -1,5 +1,9 @@ package FixMyStreet::App::Controller::Offline; + +use Image::Size; +use JSON::MaybeXS; use Moose; +use Path::Tiny; use namespace::autoclean; BEGIN { extends 'Catalyst::Controller'; } @@ -10,35 +14,72 @@ FixMyStreet::App::Controller::Offline - Catalyst Controller =head1 DESCRIPTION -Offline pages Catalyst Controller. +Offline pages Catalyst Controller - service worker handling =head1 METHODS =cut -sub have_appcache : Private { +sub service_worker : Path("/service-worker.js") { my ($self, $c) = @_; - return $c->user_exists && $c->user->has_body_permission_to('planned_reports') - && !($c->user->is_superuser && FixMyStreet->staging_flag('enable_appcache', 0)); + $c->res->content_type('application/javascript'); } -sub manifest : Path("/offline/appcache.manifest") { +sub fallback : Local { my ($self, $c) = @_; - unless ($c->forward('have_appcache')) { - $c->response->status(404); - $c->response->body('NOT FOUND'); - } - $c->res->content_type('text/cache-manifest; charset=utf-8'); - $c->res->header(Cache_Control => 'no-cache, no-store'); } -sub appcache : Path("/offline/appcache") { +sub manifest: Path("/.well-known/manifest.webmanifest") { my ($self, $c) = @_; - $c->detach('/page_error_404_not_found', []) if keys %{$c->req->params} && !$c->req->query_keywords; - unless ($c->forward('have_appcache')) { - $c->response->status(404); - $c->response->body('NOT FOUND'); + $c->res->content_type('application/manifest+json'); + + my $theme = $c->model('DB::ManifestTheme')->find({ cobrand => $c->cobrand->moniker }); + unless ( $theme ) { + $theme = $c->model('DB::ManifestTheme')->new({ + name => $c->stash->{site_name}, + short_name => $c->stash->{site_name}, + background_colour => '#ffffff', + theme_colour => '#ffd000', + }); } + + my @icons; + my $uri = '/theme/' . $c->cobrand->moniker; + my $theme_path = path(FixMyStreet->path_to('web' . $uri)); + $theme_path->visit( + sub { + my ($x, $y, $typ) = Image::Size::imgsize($_->stringify); + push @icons, { + src => join('/', $uri, $_->basename), + sizes => join('x', $x, $y), + type => $typ eq 'PNG' ? 'image/png' : $typ eq 'GIF' ? 'image/gif' : $typ eq 'JPG' ? 'image/jpeg' : '', + }; + } + ); + + unless (@icons) { + push @icons, + { src => "/cobrands/fixmystreet/images/192.png", sizes => "192x192", type => "image/png" }, + { src => "/cobrands/fixmystreet/images/512.png", sizes => "512x512", type => "image/png" }; + } + + my $data = { + name => $theme->name, + short_name => $theme->short_name, + background_color => $theme->background_colour, + theme_color => $theme->theme_colour, + icons => \@icons, + lang => $c->stash->{lang_code}, + display => "minimal-ui", + start_url => "/?pwa", + scope => "/", + }; + if ($c->cobrand->can('manifest')) { + $data = { %$data, %{$c->cobrand->manifest} }; + } + + my $json = encode_json($data); + $c->res->body($json); } __PACKAGE__->meta->make_immutable; diff --git a/perllib/FixMyStreet/App/Controller/Root.pm b/perllib/FixMyStreet/App/Controller/Root.pm index fb6d063be..caaa260ff 100644 --- a/perllib/FixMyStreet/App/Controller/Root.pm +++ b/perllib/FixMyStreet/App/Controller/Root.pm @@ -159,11 +159,6 @@ sub check_login_required : Private { }x; return if $c->request->path =~ $whitelist; - # Blacklisted URLs immediately 404 - # This is primarily to work around a Safari bug where the appcache - # URL is requested in an infinite loop if it returns a 302 redirect. - $c->detach('/page_error_404_not_found', []) if $c->request->path =~ /^offline/; - $c->detach( '/auth/redirect' ); } diff --git a/perllib/FixMyStreet/Cobrand/FixMyStreet.pm b/perllib/FixMyStreet/Cobrand/FixMyStreet.pm index 8339ceede..ba82949c7 100644 --- a/perllib/FixMyStreet/Cobrand/FixMyStreet.pm +++ b/perllib/FixMyStreet/Cobrand/FixMyStreet.pm @@ -351,4 +351,13 @@ sub update_email_shortlisted_user { FixMyStreet::Cobrand::TfL::update_email_shortlisted_user($self, $update); } +sub manifest { + return { + related_applications => [ + { platform => 'play', url => 'https://play.google.com/store/apps/details?id=org.mysociety.FixMyStreet', id => 'org.mysociety.FixMyStreet' }, + { platform => 'itunes', url => 'https://apps.apple.com/gb/app/fixmystreet/id297456545', id => 'id297456545' }, + ], + }; +} + 1; diff --git a/perllib/FixMyStreet/DB/Result/ManifestTheme.pm b/perllib/FixMyStreet/DB/Result/ManifestTheme.pm new file mode 100644 index 000000000..a2f49eacb --- /dev/null +++ b/perllib/FixMyStreet/DB/Result/ManifestTheme.pm @@ -0,0 +1,47 @@ +use utf8; +package FixMyStreet::DB::Result::ManifestTheme; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +use strict; +use warnings; + +use base 'DBIx::Class::Core'; +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); +__PACKAGE__->table("manifest_theme"); +__PACKAGE__->add_columns( + "id", + { + data_type => "integer", + is_auto_increment => 1, + is_nullable => 0, + sequence => "manifest_theme_id_seq", + }, + "cobrand", + { data_type => "text", is_nullable => 0 }, + "name", + { data_type => "text", is_nullable => 0 }, + "short_name", + { data_type => "text", is_nullable => 0 }, + "background_colour", + { data_type => "text", is_nullable => 1 }, + "theme_colour", + { data_type => "text", is_nullable => 1 }, + "images", + { data_type => "text[]", is_nullable => 1 }, +); +__PACKAGE__->set_primary_key("id"); +__PACKAGE__->add_unique_constraint("manifest_theme_cobrand_key", ["cobrand"]); + + +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2020-01-30 14:30:42 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:Sgbva7nEVkjqG/+lQL/ryw + + +# You can replace this text with custom code or comments, and it will be preserved on regeneration +1; diff --git a/perllib/FixMyStreet/TestMech.pm b/perllib/FixMyStreet/TestMech.pm index 5bb975104..1b7fba1bd 100644 --- a/perllib/FixMyStreet/TestMech.pm +++ b/perllib/FixMyStreet/TestMech.pm @@ -585,7 +585,7 @@ sub get_ok_json { # check that the content-type of response is correct croak "Response was not JSON" - unless $res->header('Content-Type') =~ m{^application/json\b}; + unless $res->header('Content-Type') =~ m{^application/(?:[a-z]+\+)?json\b}; return decode_json( $res->content ); } diff --git a/t/app/controller/offline.t b/t/app/controller/offline.t new file mode 100644 index 000000000..d2a5009ec --- /dev/null +++ b/t/app/controller/offline.t @@ -0,0 +1,48 @@ +use FixMyStreet::TestMech; +use Path::Tiny; + +my $mech = FixMyStreet::TestMech->new; + +FixMyStreet::override_config { + ALLOWED_COBRANDS => 'test' +}, sub { + my $theme_dir = path(FixMyStreet->path_to('web/theme/test')); + $theme_dir->mkpath; + my $image_path = path('t/app/controller/sample.jpg'); + $image_path->copy($theme_dir->child('sample.jpg')); + subtest 'manifest' => sub { + my $j = $mech->get_ok_json('/.well-known/manifest.webmanifest'); + is $j->{name}, 'FixMyStreet', 'correct name'; + is $j->{theme_color}, '#ffd000', 'correct theme colour'; + is_deeply $j->{icons}[0], { + type => 'image/jpeg', + src => '/theme/test/sample.jpg', + sizes => '133x100' + }, 'correct icon'; + }; + $theme_dir->remove_tree; +}; + +FixMyStreet::override_config { + ALLOWED_COBRANDS => 'fixmystreet' +}, sub { + subtest '.com manifest' => sub { + my $j = $mech->get_ok_json('/.well-known/manifest.webmanifest'); + is $j->{related_applications}[0]{platform}, 'play', 'correct app'; + is $j->{icons}[0]{sizes}, '192x192', 'correct fallback size'; + }; +}; + +subtest 'service worker' => sub { + $mech->get_ok('/service-worker.js'); + $mech->content_contains('translation_strings'); + $mech->content_contains('offline/fallback'); +}; + +subtest 'offline fallback page' => sub { + $mech->get_ok('/offline/fallback'); + $mech->content_contains('Offline'); + $mech->content_contains('offline_list'); +}; + +done_testing();
\ No newline at end of file diff --git a/t/app/controller/root.t b/t/app/controller/root.t index 85119da24..0263b65d7 100644 --- a/t/app/controller/root.t +++ b/t/app/controller/root.t @@ -60,18 +60,6 @@ FixMyStreet::override_config { is $mech->res->previous, undef, 'No redirect'; } }; - - subtest 'LOGIN_REQUIRED = 1 404s blacklisted URLs' => sub { - my @blacklist = ( - '/offline/appcache', - ); - - foreach my $url (@blacklist) { - $mech->get($url); - ok !$mech->res->is_success(), "want a bad response"; - is $mech->res->code, 404, "got 404"; - } - }; }; subtest "check_login_disallowed cobrand hook" => sub { diff --git a/templates/web/base/common_header_tags.html b/templates/web/base/common_header_tags.html index 728b81363..279f561df 100644 --- a/templates/web/base/common_header_tags.html +++ b/templates/web/base/common_header_tags.html @@ -1,6 +1,7 @@ [% SET start = c.config.ADMIN_BASE_URL IF admin %] <meta http-equiv="content-type" content="text/html; charset=utf-8"> +<link rel="manifest" href="/.well-known/manifest.webmanifest"> [% IF csrf_token %] <meta content="[% csrf_token %]" name="csrf-token" /> @@ -15,6 +16,14 @@ (function(a){a=a.documentElement;a.className=a.className.replace(/\bno-js\b/,"js");var b=-1<a.className.indexOf("ie8");b=Modernizr.mq("(min-width: 48em)")||b?"desktop":"mobile";"IntersectionObserver"in window&&(a.className+=" lazyload");"mobile"==b&&(a.className+=' mobile[% " map-fullscreen only-map map-reporting" IF page == "around" %]')})(document); </script> +<script nonce="[% csp_nonce %]"> +if ('serviceWorker' in navigator) { + window.addEventListener('load', function() { + navigator.serviceWorker.register('/service-worker.js'); + }); +} +</script> + [% IF robots %] <meta name="robots" content="[% robots %]"> [% ELSIF c.config.STAGING_SITE %] diff --git a/templates/web/base/common_scripts.html b/templates/web/base/common_scripts.html index 7df8feaa0..f94b3d464 100644 --- a/templates/web/base/common_scripts.html +++ b/templates/web/base/common_scripts.html @@ -46,6 +46,7 @@ IF c.user_exists AND (c.user.from_body OR c.user.is_superuser); END; IF c.user.has_body_permission_to('planned_reports'); scripts.push( + 'https://cdn.jsdelivr.net/npm/idb-keyval@3/dist/idb-keyval-iife.min.js', version('/cobrands/fixmystreet/offline.js'), ); END; diff --git a/templates/web/base/header.html b/templates/web/base/header.html index 74ec2d16a..b3d77b3a6 100644 --- a/templates/web/base/header.html +++ b/templates/web/base/header.html @@ -6,10 +6,7 @@ <!doctype html> <!--[if IE 8]> <html class="no-js ie8"[% html_att | safe %]><![endif]--> <!--[if IE 9]> <html class="no-js ie9"[% html_att | safe %]><![endif]--> -<!--[if gt IE 9]><!--><html class="no-js"[% html_att | safe %] -[% IF appcache ~%] - manifest="/offline/appcache.manifest" -[%~ END %]><!--<![endif]--> +<!--[if gt IE 9]><!--><html class="no-js"[% html_att | safe %]><!--<![endif]--> <head> <meta name="viewport" content="initial-scale=1.0"> diff --git a/templates/web/base/offline/appcache.html b/templates/web/base/offline/fallback.html index ed48b7a00..b8e1ee9b9 100644 --- a/templates/web/base/offline/appcache.html +++ b/templates/web/base/offline/fallback.html @@ -1,11 +1,9 @@ [% SET bodyclass = "fullwidthpage offlinepage" ~%] -[% INCLUDE 'header.html' appcache = 1 %] +[% INCLUDE 'header.html' %] -<h1>[% loc('Internet glitch') %]</h1> +<h1>[% loc('Offline') %]</h1> -<p>[% loc('Sorry, we don’t have a good enough connection to fetch that page, or the -page wasn’t found or there was a server error. Please try again later.') %] -</p> +<p>[% loc('Sorry, we don’t have a good enough connection to fetch that page.') %]</p> <ul class="item-list item-list--reports" id="offline_list"></ul> diff --git a/templates/web/base/offline/manifest.html b/templates/web/base/offline/manifest.html deleted file mode 100644 index 93d26cb94..000000000 --- a/templates/web/base/offline/manifest.html +++ /dev/null @@ -1,16 +0,0 @@ -CACHE MANIFEST - -[% PROCESS 'common_scripts.html' ~%] - -CACHE: -[% version('/cobrands/' _ c.cobrand.asset_moniker _ '/base.css') %] -[% version('/cobrands/' _ c.cobrand.asset_moniker _ '/layout.css') %] -[% FOR script IN scripts ~%] - [%- script %] -[% END %] - -NETWORK: -* - -FALLBACK: -/ [% version('../templates/web/base/offline/appcache.html', '/offline/appcache') %] diff --git a/templates/web/base/offline/service_worker.html b/templates/web/base/offline/service_worker.html new file mode 100644 index 000000000..0feb26ce6 --- /dev/null +++ b/templates/web/base/offline/service_worker.html @@ -0,0 +1,104 @@ +[% +SET bodyclass = "offlinepage"; # For selection of scripts +PROCESS 'common_scripts.html'; +SET offline_html = version('../templates/web/base/offline/fallback.html', '/offline/fallback'); +SET scripts_seen = {}; + +~%] + +importScripts('https://cdn.jsdelivr.net/npm/idb-keyval@3/dist/idb-keyval-iife.min.js'); + +const requiredOffline = [ + "[% version('/cobrands/' _ c.cobrand.asset_moniker _ '/base.css') %]", + "[% version('/cobrands/' _ c.cobrand.asset_moniker _ '/layout.css') %]", + "[% version('/vendor/OpenLayers/theme/default/style.css') %]", + "[% version('/vendor/fancybox/jquery.fancybox-1.3.4.css') %]", + [% + FOR script IN scripts; + NEXT IF scripts_seen.${script}; + scripts_seen.${script} = 1; + ~%] + "[%- script %]", + [% END %] + "[% offline_html %]" +]; + +const staticCache = 'static'; +// const pageCache = 'pages'; + +addEventListener('install', function(evt) { + evt.waitUntil(precache()); +}); + +async function precache() { + const cache = await caches.open(staticCache); + return cache.addAll(requiredOffline); +} + +addEventListener('fetch', fetchEvent => { + const request = fetchEvent.request; + const url = new URL(request.url); + + if (url.origin !== location.origin) { + return; + } + + // Handle inspection form submission if offline... + if (request.method === 'POST' && RegExp('/report/\\d+$').test(url)) { + fetchEvent.respondWith(async function() { + const fetchPromise = fetch(request.clone()); + try { + return await fetchPromise; + } + catch { + fetchEvent.waitUntil(async function() { + var text = await request.text(); + let formData = new URLSearchParams(text); + formData.set('save', 2); + formData.set('saved_at', Math.floor(+new Date() / 1000)); + formData = formData.toString(); + + var data = await idbKeyval.get('offlineData') || { cachedReports: {}, forms: [] }; + var forms = data.forms; + if (!forms.length || formData != forms[forms.length - 1][1]) { + forms.push([request.url, formData]); + } + return idbKeyval.set('offlineData', data); + }()); + + return Response.redirect('/my/planned?saved=1'); + }; + }()); + } + + if (request.method !== "GET") { + return; + } + + fetchEvent.respondWith(async function() { + if (request.mode === 'navigate') { + const fetchPromise = fetch(request); + +// For now, only save pages manually for inspectors +// fetchEvent.waitUntil(async function() { +// const responseCopy = (await fetchPromise).clone(); +// const cache = await caches.open(pageCache); +// await responseCopy.ok ? cache.put(request, responseCopy) : cache.delete(request); +// }()); + + try { + return await fetchPromise; + } + catch { + let cached = await caches.match(request) || await caches.match("[% offline_html %]"); + return cached || offlineResponse(); + } + } else { + const responseFromCache = await caches.match(request); + return responseFromCache || fetch(request); + } + }()); +}); + +var offlineResponse = () => + new Response('Service Unavailable', { status: 503, statusText: 'Service Unavailable', headers: { 'Content-Type': 'text/html' }}); diff --git a/templates/web/oxfordshire/header.html b/templates/web/oxfordshire/header.html index 70d7e4b18..72cba2aec 100644 --- a/templates/web/oxfordshire/header.html +++ b/templates/web/oxfordshire/header.html @@ -1,10 +1,7 @@ <!doctype html> <!--[if IE 8]> <html class="no-js ie8" lang="[% lang_code %]"><![endif]--> <!--[if IE 9]> <html class="no-js ie9" lang="[% lang_code %]"><![endif]--> -<!--[if gt IE 9]><!--><html class="no-js" lang="[% lang_code %]" -[% IF appcache ~%] - manifest="/offline/appcache.manifest" -[%~ END %]><!--<![endif]--> +<!--[if gt IE 9]><!--><html class="no-js" lang="[% lang_code %]"><!--<![endif]--> <head> <meta name="viewport" content="initial-scale=1.0"> diff --git a/web/cobrands/fixmystreet/images/192.png b/web/cobrands/fixmystreet/images/192.png Binary files differnew file mode 100755 index 000000000..e43a3bb37 --- /dev/null +++ b/web/cobrands/fixmystreet/images/192.png diff --git a/web/cobrands/fixmystreet/images/512.png b/web/cobrands/fixmystreet/images/512.png Binary files differnew file mode 100755 index 000000000..13cf165d3 --- /dev/null +++ b/web/cobrands/fixmystreet/images/512.png diff --git a/web/cobrands/fixmystreet/offline.js b/web/cobrands/fixmystreet/offline.js index 4d7c471aa..908326a69 100644 --- a/web/cobrands/fixmystreet/offline.js +++ b/web/cobrands/fixmystreet/offline.js @@ -8,8 +8,7 @@ fixmystreet.offlineBanner = (function() { } // Note this non-global way of handling plurals may need looking at in future - function formText() { - var num = fixmystreet.offlineData.getForms().length; + function formText(num) { if ( num === 1 ) { return num + ' ' + translation_strings.offline.update_single; } else { @@ -17,12 +16,12 @@ fixmystreet.offlineBanner = (function() { } } - function onlineText() { - return sprintf(translation_strings.offline.saved_to_submit, formText()); + function onlineText(num) { + return sprintf(translation_strings.offline.saved_to_submit, formText(num)); } - function offlineText() { - return translation_strings.offline.you_are_offline + ' \u2013 ' + sprintf(translation_strings.offline.N_saved, formText()); + function offlineText(num) { + return translation_strings.offline.you_are_offline + ' \u2013 ' + sprintf(translation_strings.offline.N_saved, formText(num)); } function remove() { @@ -31,24 +30,29 @@ fixmystreet.offlineBanner = (function() { return { make: function(offline) { - var num = fixmystreet.offlineData.getForms().length; - var banner = ['<div class="top_banner top_banner--offline"><p><span id="offline_saving"></span> <span id="offline_forms">']; - if (offline || num > 0) { - banner.push(offline ? offlineText() : onlineText()); - } - banner.push('</span></p></div>'); - banner = $(banner.join('')); - banner.prependTo('.content'); - if (num === 0) { - banner.hide(); - } + fixmystreet.offlineData.getFormsLength().then(function(num) { + var banner = ['<div class="top_banner top_banner--offline"><p><span id="offline_saving"></span> <span id="offline_forms">']; + if (offline || num > 0) { + banner.push(offline ? offlineText(num) : onlineText(num)); + } + banner.push('</span></p></div>'); + banner = $(banner.join('')); + banner.prependTo('.content'); + if (num === 0) { + banner.hide(); + } + }); window.addEventListener("offline", function(e) { - $('#offline_forms').html(offlineText()); + fixmystreet.offlineData.getFormsLength().then(function(num) { + $('#offline_forms').html(offlineText(num)); + }); }); window.addEventListener("online", function(e) { - $('#offline_forms').html(onlineText()); + fixmystreet.offlineData.getFormsLength().then(function(num) { + $('#offline_forms').html(onlineText(num)); + }); }); function nextForm(DataOrJqXHR, textStatus, jqXHROrErrorThrown) { @@ -62,37 +66,40 @@ fixmystreet.offlineBanner = (function() { $(document).on('click', '#oFN', function(e) { e.preventDefault(); - fixmystreet.offlineData.getForms().forEach(function(form) { - $(document).queue('postForm', function() { - postForm(form[0], form[1]).fail(function(jqXHR) { - if (jqXHR.status !== 400) { - return nextForm(); - } - // In case the request failed due to out-of-date CSRF token, - // try once more with a new token given in the error response. - var m = jqXHR.responseText.match(/content="([^"]*)" name="csrf-token"/); - if (!m) { - return nextForm(); - } - var token = m[1]; - if (!token) { - return nextForm(); - } - var param = form[1].replace(/&token=[^&]*/, '&token=' + token); - return postForm(form[0], param).fail(nextForm); + fixmystreet.offlineData.getForms().then(function(forms) { + forms.forEach(function(form) { + $(document).queue('postForm', function() { + postForm(form[0], form[1]).fail(function(jqXHR) { + if (jqXHR.status !== 400) { + return nextForm(); + } + // In case the request failed due to out-of-date CSRF token, + // try once more with a new token given in the error response. + var m = jqXHR.responseText.match(/content="([^"]*)" name="csrf-token"/); + if (!m) { + return nextForm(); + } + var token = m[1]; + if (!token) { + return nextForm(); + } + var param = form[1].replace(/&token=[^&]*/, '&token=' + token); + return postForm(form[0], param).fail(nextForm); + }); }); }); + $(document).dequeue('postForm'); }); - $(document).dequeue('postForm'); }); }, update: function() { $('.top_banner--offline').slideDown(); - $('#offline_forms span').text(formText()); - var num = fixmystreet.offlineData.getForms().length; - if (num === 0) { - window.setTimeout(remove, 3000); - } + fixmystreet.offlineData.getFormsLength().then(function(num) { + $('#offline_forms span').text(formText(num)); + if (num === 0) { + window.setTimeout(remove, 3000); + } + }); }, startProgress: function(l) { $('.top_banner--offline').slideDown(); @@ -115,61 +122,55 @@ fixmystreet.offlineData = (function() { var data; function getData() { - if (data === undefined) { - data = JSON.parse(localStorage.getItem('offlineData')); - if (!data) { - data = { cachedReports: {}, forms: [] }; - } + if (data !== undefined) { + return Promise.resolve(data); } - return data; + return idbKeyval.get('offlineData').then(function(d) { + data = d || { cachedReports: {}, forms: [] }; + return data; + }); } - function saveData() { - localStorage.setItem('offlineData', JSON.stringify(getData())); + function updateData(cb) { + getData().then(function(data) { + cb(data); + idbKeyval.set('offlineData', data); + }); } return { - getForms: function() { - return getData().forms; + getFormsLength: function() { + return getData().then(function(data) { return data.forms.length; }); }, - addForm: function(action, formData) { - var forms = getData().forms; - if (!forms.length || formData != forms[forms.length - 1][1]) { - forms.push([action, formData]); - saveData(); - } - fixmystreet.offlineBanner.update(); + getForms: function() { + return getData().then(function(data) { return data.forms; }); }, shiftForm: function(idx) { - getData().forms.shift(); - saveData(); - fixmystreet.offlineBanner.update(); + updateData(function(data) { + data.forms.shift(); + fixmystreet.offlineBanner.update(); + }); }, clearForms: function(idx) { - getData().forms = []; - saveData(); - fixmystreet.offlineBanner.update(); + updateData(function(data) { + data.forms = []; + fixmystreet.offlineBanner.update(); + }); }, - getCachedUrls: function() { - return Object.keys(getData().cachedReports); - }, - isIndexed: function(url, lastupdate) { - if (lastupdate) { - return getData().cachedReports[url] === lastupdate; - } - return !!getData().cachedReports[url]; + getCachedReports: function() { + return getData().then(function(data) { return data.cachedReports; }); }, add: function(url, lastupdate) { - var data = getData(); - data.cachedReports[url] = lastupdate || "-"; - saveData(); + updateData(function(data) { + data.cachedReports[url] = lastupdate || "-"; + }); }, remove: function(urls) { - var data = getData(); - urls.forEach(function(url) { - delete data.cachedReports[url]; + updateData(function(data) { + urls.forEach(function(url) { + delete data.cachedReports[url]; + }); }); - saveData(); } }; })(); @@ -177,57 +178,39 @@ fixmystreet.offlineData = (function() { fixmystreet.cachet = (function(){ var urlsInProgress = {}; - function cacheURL(url, type) { + function cacheURL(url) { urlsInProgress[url] = 1; - - var ret; - if (type === 'image') { - ret = $.Deferred(function(deferred) { - var oReq = new XMLHttpRequest(); - oReq.open("GET", url, true); - oReq.responseType = "blob"; - oReq.onload = function(oEvent) { - var blob = oReq.response; - var reader = new window.FileReader(); - reader.readAsDataURL(blob); - reader.onloadend = function() { - localStorage.setItem(url, reader.result); + return caches.open('pages').then(function(cache) { + return fetch(url).then(function(response) { + if (response.ok) { + cache.put(url, response.clone()).then(function() { delete urlsInProgress[url]; - deferred.resolve(blob); - }; - }; - oReq.send(); - }); - } else { - ret = $.ajax(url).pipe(function(content, textStatus, jqXHR) { - localStorage.setItem(url, content); - delete urlsInProgress[url]; - return content; + }); + } + return response; }); - } - return ret; + }); } function cacheReport(item) { - return cacheURL(item.url, 'html').pipe(function(html) { + return cacheURL(item.url).then(function(response) { + return response.text(); + }).then(function(html) { var $reportPage = $(html); var imagesToGet = [ item.url + '/map' // Static map image ]; $reportPage.find('img').each(function(i, img) { - if (img.src.indexOf('/photo/') === -1 || fixmystreet.offlineData.isIndexed(img.src) || urlsInProgress[img.src]) { + if (img.src.indexOf('/photo/') === -1 || urlsInProgress[img.src]) { return; } imagesToGet.push(img.src); imagesToGet.push(img.src.replace('.jpeg', '.fp.jpeg')); }); var imagePromises = imagesToGet.map(function(url) { - return cacheURL(url, 'image'); + return cacheURL(url); }); - return $.when.apply(undefined, imagePromises).pipe(function() { - fixmystreet.offlineBanner.progress(); - fixmystreet.offlineData.add(item.url, item.lastupdate); - }, function() { + return Promise.all(imagePromises).finally(function() { fixmystreet.offlineBanner.progress(); fixmystreet.offlineData.add(item.url, item.lastupdate); }); @@ -241,7 +224,7 @@ fixmystreet.cachet = (function(){ var promises = items.map(function(item) { return cacheReport(item); }); - return $.when.apply(undefined, promises); + return Promise.all(promises); } return { @@ -265,130 +248,101 @@ fixmystreet.offline = (function() { var toRemove = []; var shouldBeCached = {}; - localStorage.setItem('/my/planned', $('.item-list').html()); + idbKeyval.set('/my/planned', $('.item-list').html()); - getReportsFromList().forEach(function(item, i) { - if (!fixmystreet.offlineData.isIndexed(item.url, item.lastupdate)) { - toCache.push(item); - } - shouldBeCached[item.url] = 1; - }); + fixmystreet.offlineData.getCachedReports().then(function(reports) { + getReportsFromList().forEach(function(item, i) { + var t = reports[item.url]; + if (t !== item.lastupdate) { + toCache.push(item); + } + shouldBeCached[item.url] = 1; + }); + + Object.keys(reports).forEach(function(url) { + if ( !shouldBeCached[url] ) { + toRemove.push(url); + } + }); - fixmystreet.offlineData.getCachedUrls().forEach(function(url) { - if ( !shouldBeCached[url] ) { - toRemove.push(url); + if (toRemove[0]) { + removeReports(toRemove); + } + if (toCache[0]) { + fixmystreet.cachet.cacheReports(toCache); } }); - - if (toRemove[0]) { - removeReports(toRemove); - } - if (toCache[0]) { - fixmystreet.cachet.cacheReports(toCache); - } } // Remove a list of reports from the offline cache function removeReports(urls) { - var pathsRemoved = []; - urls.forEach(function(url) { - var html = localStorage.getItem(url); - var $reportPage = $(html); - localStorage.removeItem(url + '/map'); - $reportPage.find('img').each(function(i, img) { - if (img.src.indexOf('/photo/') === -1) { - return; - } - localStorage.removeItem(img.src); - localStorage.removeItem(img.src.replace('.jpeg', '.fp.jpeg')); + caches.open('pages').then(function(cache) { + urls.forEach(function(url) { + fetch(url).then(function(response) { + return response.text(); + }).then(function(html) { + var $reportPage = $(html); + cache.delete(url + '/map'); + $reportPage.find('img').each(function(i, img) { + if (img.src.indexOf('/photo/') === -1) { + return; + } + cache.delete(img.src); + cache.delete(img.src.replace('.jpeg', '.fp.jpeg')); + }); + cache.delete(url); + }); }); - localStorage.removeItem(url); + fixmystreet.offlineData.remove(urls); }); - fixmystreet.offlineData.remove(urls); } - function showReportFromCache(url) { - var html = localStorage.getItem(url); - if (!html) { - return false; - } - var map = localStorage.getItem(url + '/map'); - var found = html.match(/<body[^>]*>[\s\S]*<\/body>/); - document.body.outerHTML = found[0]; - $('#map_box').html('<img src="' + map + '">').css({ textAlign: 'center', height: 'auto' }); - replaceImages('img'); - + function showReportOffline(url) { + $('#map_box').html('<img src="' + url + '/map">').css({ textAlign: 'center', height: 'auto' }); $('.moderate-display.segmented-control, .shadow-wrap, #update_form, #report-cta, .mysoc-footer, .nav-wrapper').hide(); - $('.js-back-to-report-list').attr('href', '/my/planned'); // Refill form with saved data if there is any - var savedForm; - fixmystreet.offlineData.getForms().forEach(function(form) { - if (form[0].endsWith(url)) { - savedForm = form[1]; - } - }); - if (savedForm) { - savedForm.replace(/\+/g, '%20').split('&').forEach(function(kv) { - kv = kv.split('=', 2); - if (kv[0] != 'include_update' && kv[0] != 'public_update' && kv[0] != 'save') { - $('[name=' + kv[0] + ']').val(decodeURIComponent(kv[1])); + fixmystreet.offlineData.getForms().then(function(forms) { + var savedForm; + forms.forEach(function(form) { + if (form[0].endsWith(url)) { + savedForm = form[1]; } }); - } - - // If we catch the form submit, e.g. Chrome still seems to - // try and submit and we get the Chrome offline error page - var btn = $('#report_inspect_form input[type=submit]'); - btn.click(function() { - var form = $(this).closest('form'); - var data = form.serialize() + '&save=1&saved_at=' + Math.floor(+new Date() / 1000); - fixmystreet.offlineData.addForm(form.attr('action'), data); - location.href = '/my/planned?saved=1'; - return false; - }); - btn[0].type = 'button'; - - return true; - } - - function replaceImages(selector) { - $(selector).each(function(i, img) { - if (img.src.indexOf('/photo/') > -1) { - var dataImg = localStorage.getItem(img.src); - if (dataImg) { - img.src = dataImg; - } + if (savedForm) { + savedForm.replace(/\+/g, '%20').split('&').forEach(function(kv) { + kv = kv.split('=', 2); + if (kv[0] != 'include_update' && kv[0] != 'public_update' && kv[0] != 'save') { + $('[name=' + kv[0] + ']').val(decodeURIComponent(kv[1])); + } + }); } }); } return { - replaceImages: replaceImages, - showReportFromCache: showReportFromCache, + showReportOffline: showReportOffline, removeReports: removeReports, updateCachedReports: updateCachedReports }; })(); +if (!navigator.onLine && location.pathname.indexOf('/report') === 0) { + fixmystreet.offline.showReportOffline(location.pathname); +} + if ($('#offline_list').length) { // We are OFFLINE - var success = false; - if (location.pathname.indexOf('/report') === 0) { - success = fixmystreet.offline.showReportFromCache(location.pathname); - } - if (!success) { - var html = localStorage.getItem('/my/planned'); - if (html) { - $('#offline_list').before('<h2>'+translation_strings.offline.your_reports+'</h2>'); - $('#offline_list').html(html); - if (location.search.indexOf('saved=1') > 0) { - $('#offline_list').before('<p class="form-success">'+translation_strings.offline.update_saved+'</p>'); - } - fixmystreet.offline.replaceImages('#offline_list img'); - var offlineForms = fixmystreet.offlineData.getForms(); + idbKeyval.get('/my/planned').then(function(html) { + if (!html) { return; } + $('#offline_list').before('<h2>'+translation_strings.offline.your_reports+'</h2>'); + $('#offline_list').html(html); + if (location.search.indexOf('saved=1') > 0) { + $('#offline_list').before('<p class="form-success">'+translation_strings.offline.update_saved+'</p>'); + } + fixmystreet.offlineData.getForms().then(function(offlineForms) { var savedForms = {}; offlineForms.forEach(function(form) { savedForms[form[0]] = 1; @@ -398,25 +352,21 @@ if ($('#offline_list').length) { $(this).find('h3').prepend('<em>'+translation_strings.offline.update_data_saved+'</em> '); } }); - $('#offline_clear').css('margin-top', '5em').html('<button id="js-clear-localStorage">'+translation_strings.offline.clear_data+'</button>'); - $('#js-clear-localStorage').click(function() { - if (window.confirm(translation_strings.offline.are_you_sure)) { - fixmystreet.offline.removeReports(fixmystreet.offlineData.getCachedUrls()); - fixmystreet.offlineData.clearForms(); - localStorage.removeItem('/my/planned'); - alert(translation_strings.offline.data_cleared); - } - }); - } - } + }); + $('#offline_clear').css('margin-top', '5em').html('<button id="js-clear-storage">'+translation_strings.offline.clear_data+'</button>'); + $('#js-clear-storage').click(function() { + if (window.confirm(translation_strings.offline.are_you_sure)) { + fixmystreet.offlineData.getCachedReports().then(function(reports) { + fixmystreet.offline.removeReports(Object.keys(reports)); + }); + fixmystreet.offlineData.clearForms(); + idbKeyval.del('/my/planned'); + alert(translation_strings.offline.data_cleared); + } + }); + }); fixmystreet.offlineBanner.make(true); } else { - // Put the appcache manifest in a page in an iframe so that HTML pages - // aren't cached (thanks to Jake Archibald for documenting this!) - if (window.applicationCache && window.localStorage) { - $(document.body).prepend('<iframe src="/offline/appcache" style="position:absolute;top:-999em;visibility:hidden"></iframe>'); - } - fixmystreet.offlineBanner.make(false); // On /my/planned, when online, cache all shortlisted |