2. Understanding the App Manifest

Add to home screen (A2HS) - Browser Support

https://caniuse.com/web-app-manifest

MDN Article on the Web App Manifest (includes List of all Properties)

https://developer.mozilla.org/en-US/docs/Web/Manifest

A detailed Web App Manifest Explanation by Google

https://web.dev/add-manifest/

More about the "Web App Install Banner" (including Requirements)

https://web.dev/customize-install/

2.17. Understanding App Manifest Properties

{
    "name": "Sweaty - Activity Tracker",
    "short_name": "Sweaty",
    "start_url": "/index.html",
    "scope": ".",
    "display": "standalone",
    "background_color": "#fff",
    "theme_color": "#3f51b5",
    "description": "Keep running until you're super sweaty!",
    "dir": "ltr",
    "language": "en-US",
    "orientation": "portrait-primary",
    "icons": [
        {
            "src": "/src/images/icons/app-icon-48x48.png",
            "type": "image/png",
            "size": "48x48"
        },
        {
            "src": "/src/images/icons/app-icon-96x96.png",
            "type": "image/png",
            "size": "96x96"
        }
    ],
    "related_applications": [
        {
            "platform": "play",
            "url": "https://play.google.com/store/apps/details?id=com.example.app1",
            "id": "com.example.app1"
        }
    ]

}

3. The Service Workers

The App Install Banner:

https://web.dev/customize-install/

DevTools

https://developer.chrome.com/docs/devtools/

Make sure you enabled "Developer Mode" on your Device! You do that by tapping your Android Build Number (in the Settings) 7 times.

4. Promise and Fetch

Assignment

Task 1

  // Create a new Promise here and use setTimeout inside the function you pass to the constructor
  let promise = new Promise(
    function (resolve) {

      setTimeout(function () { // <- Store this INSIDE the Promise you created!
        // Resolve the following URL: https://swapi.co/api/people/1
        resolve('https://swapi.dev/api/people/1');
      }, 3000);

    }
  );

  promise.then(function (url) {
    // Handle the Promise "response" (=> the value you resolved) and returß a fetch()
    // call to the value (= URL) you resolved (use a GET request)
    return fetch(url, { mode: "no-cors" });
  }).then(function (response) {
    // Handle the response of the fetch() call and extract the JSON data, return that
    // and handle it in yet another then() block
    return response.json();
  }).then(function (data) {
    // Finally, output the "name" property of the data you got back (e.g. data.name) inside
    // the "output" element (see variables at top of the file)
    console.log('data:', data);
    output.innerHTML = data.name;
  });

sw.js:12 Cross-Origin Read Blocking (CORB) blocked cross-origin response https://swapi.dev/api/people/1 with MIME type application/json. See https://www.chromestatus.com/feature/5629709824032768 for more details.

Task 2

  // Repeat the exercise with a PUT request you send to https://httpbin.org/put
  let promise = new Promise(
    function (resolve) {
      setTimeout(function () { // <- Store this INSIDE the Promise you created!
        resolve('https://httpbin.org/put');
      }, 1000);
    }
  );

  // Make sure to set the appropriate headers (as shown in the lecture)
  // Send any data of your choice, make sure to access it correctly when outputting it
  // Example: If you send {person: {name: 'Max', age: 28}}, you access data.json.person.name
  // to output the name (assuming your parsed JSON is stored in "data")
  promise.then(function (url) {
    return fetch(url, {
      method: "PUT",
      headers: {
        "Content-Type": "application/json",
        "Accept": "application/json"
      },
      body: JSON.stringify({
        name: "John"
      })
    });
  }).then(function (response) {
    return response.json();
  }).then(function (data) {
    console.log('data:', data);
    output.innerHTML = JSON.parse(data.data).name;
  });

Task 3

  // Handle the error in catch()

  let promise = new Promise(
    function (resolve) {
      setTimeout(function () {
        resolve('https://swapi.dev/api/people/1');
      }, 500);
    }
  );

  promise.then(function (url) {
    return fetch(url, { mode: "no-cors" });
  }).then(function (response) {
    return response.json();
  }).then(function (data) {
    console.log('-- data:', data);
    output.innerHTML = data.name;
  }).catch(function (err) {
    console.log('-- err:', err);
  });

Task 4

  // Handle the error as a second argument to then()
  let promise = new Promise(
    function (resolve) {
      setTimeout(function () {
        resolve('https://swapi.dev/api/people/1');
      }, 500);
    }
  );

  promise.then(function (url) {
    return fetch(url);
  },
  function (err) {
    console.log('-- err:', err);
  }).then(function (response) {
    return response.json();
  }).then(function (data) {
    console.log('-- data:', data);
    output.innerHTML = data.name;
  });

5. Service Workers - Caching

Useful Resources

About Cache Persistence and Storage Limits:

https://jakearchibald.com/2014/offline-cookbook/#cache-persistence

Learn more about Service Workers:

https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API

Static and Dynamic Caching

var CACHE_STATIC_NAME = "static-v2";
var CACHE_DYNAMIC_NAME = "dynamic-v2";

self.addEventListener('install', function(event) {
  console.log('[Service Worker] Installing Service Worker ...', event);
  event.waitUntil(
    caches.open(CACHE_STATIC_NAME)
      .then(function(cache) {
        console.log('[Service Worker] Precaching App Shell');
        cache.addAll([
          '/',
          '/index.html',
          '/src/js/app.js',
          '/src/js/feed.js',
          '/src/js/promise.js',
          '/src/js/fetch.js',
          '/src/js/material.min.js',
          '/src/css/app.css',
          '/src/css/feed.css',
          '/src/images/main-image.jpg',
          'https://fonts.googleapis.com/css?family=Roboto:400,700',
          'https://fonts.googleapis.com/icon?family=Material+Icons',
          'https://cdnjs.cloudflare.com/ajax/libs/material-design-lite/1.3.0/material.indigo-pink.min.css'
        ]);
      })
  );
});

self.addEventListener('activate', function(event) {
  console.log('[Service Worker] Activating Service Worker ....', event);
  event.waitUntil(
    caches.keys()
      .then(function (keyList) {
        return Promise.all(keyList.map(function (key) {
          if (key !== CACHE_STATIC_NAME && key !== CACHE_DYNAMIC_NAME)  {
            console.log('[Service Worker] Removing old cache:',key);
            return caches.delete(key);
          }
        }));
      })
  );
  return self.clients.claim();
});

self.addEventListener('fetch', function(event) {
  event.respondWith(caches.match(event.request)
    .then(function (response) {
      if (response) {
        return response;
      } else {
        return fetch(event.request)
          .then(function (res) {
            return caches.open(CACHE_DYNAMIC_NAME)
              .then(function (cache) {
                cache.put(event.request.url, res.clone());
                return res;
              });
          })
          .catch(function (err) {

          });
      }
    })
  );
});

6. Service Workers - Advanced Caching

80. Offering "Cache on Demand"

function onSaveButtonClicked(event) {
  console.log('clicked');
  if ('caches' in window) {
    caches.open('user-requested')
    .then(function (cache) {
      cache.add('https://httpbin.org/get');
      cache.add('/src/images/sf-boat.jpg');
    });
  }
}

81. Providing an Offline Fallback Page

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request)
      .then(function(response) {
        if (response) {
          return response;
        } else {
          return fetch(event.request)
            .then(function(res) {
              return caches.open(CACHE_DYNAMIC_NAME)
                .then(function(cache) {
                  cache.put(event.request.url, res.clone());
                  return res;
                })
            })
            .catch(function(err) {
              return caches.open(CACHE_STATIC_NAME)
                .then(function (cache) {
                  return cache.match('/offline.html');  (1)
                });
            });
        }
      })
  );
});
1 Return offline page stored in a static cache

82. Strategy: Cache with Network Fallback

This is what we implemented above.

85. Strategy: Network with Cache Fallback

self.addEventListener('fetch', function(event) {
  event.respondWith(
    fetch(event.request)
      .then(function (res) {
        return caches.open(CACHE_DYNAMIC_NAME)
          .then(function(cache) {
            cache.put(event.request.url, res.clone());
            return res;
          })
      })
      .catch(function (err) {
        return caches.match(event.request);
      })
  );
});

86. Strategy: Cache then Network

feed.js
var url = 'https://httpbin.org/get';
var networkDataReceived = false;

fetch(url)
  .then(function(res) {
    return res.json();
  })
  .then(function(data) {
    networkDataReceived = true;
    console.log('From web', data);
    clearCards();
    createCard();
  });

if ('caches' in window) {
  caches.match(url)
    .then(function (response) {
      if (response) {
        return response.json();
      }
    })
    .then(function (data) {
      console.log('From cache', data);
      if (!networkDataReceived) {
        clearCards();
        createCard();
      }
    })
}

87. Cache then Network & Dynamic Caching

sw.js
self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.open(CACHE_DYNAMIC_NAME)
      .then(function (cache) {
        return fetch(event.request)
          .then(function (res) {
            cache.put(event.request, res.clone());
            return res;
          })
      })
  );
});

88. Cache then Network with Offline Support

sw.js
self.addEventListener('fetch', function(event) {
  var url = 'https://httpbin.org/get';

  if (event.request.url.indexOf(url) > -1) {
    event.respondWith(
      caches.open(CACHE_DYNAMIC_NAME)
        .then(function (cache) {
          return fetch(event.request)
            .then(function (res) {
              cache.put(event.request, res.clone());
              return res;
            })
        })
    );
  } else {
    event.respondWith(
      caches.match(event.request)
        .then(function(response) {
          if (response) {
            return response;
          } else {
            return fetch(event.request)
              .then(function(res) {
                return caches.open(CACHE_DYNAMIC_NAME)
                  .then(function(cache) {
                    cache.put(event.request.url, res.clone());
                    return res;
                  })
              })
              .catch(function(err) {
                return caches.open(CACHE_STATIC_NAME)
                  .then(function (cache) {
                    return cache.match('/offline.html');
                  });
              });
          }
        })
      );
  }

});

7. IndexedDB and Dynamic Data

utility.js
var dbPromise = idb.open('posts-store', 1, function (db) {
    if (!db.objectStoreNames.contains('posts')) {
        db.createObjectStore('posts', { keyPath: 'id' });
    }
});

function writeData(st, data) {
    return dbPromise
        .then(function (db) {
            var tx = db.transaction(st, 'readwrite');
            var store = tx.objectStore(st);
            store.put(data);
            return tx.complete;
        });
}

function readAllData(st) {
    return dbPromise
        .then(function (db) {
            var tx = db.transaction(st, 'readonly');
            var store = tx.objectStore(st);
            return store.getAll();
        });
}

function clearAllData(st) {
    return dbPromise
        .then(function (db) {
            var tx = db.transaction(st, 'readwrite');
            var store = tx.objectStore(st);
            store.clear();
            return tx.complete;
        });
}

function deleteItemFromData(st, id) {
    dbPromise
        .then(function (db) {
            var tx = db.transaction(st, 'readwrite');
            var store = tx.objectStore(st);
            store.delete(id);
            return tx.complete;
        })
        .then(function () { 
            console.log('Item deleted');
        });
}

Useful Resources

IDB on Github:

https://github.com/jakearchibald/idb

IndexedDB explained on MDN:

https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API

Alternative to IDB:

http://dexie.org/

8. Creating a Responsive User Interface

9. Background Sync

Useful Resources

Table 1. Firebase Functions

Firebase Pricing:

https://firebase.google.com/pricing

Firebase Console:

https://console.firebase.google.com

Firebase Hosting:

https://firebase.google.com/docs/hosting

Introducing Background Sync:

https://developer.chrome.com/blog/background-sync/

A Basic Guide to Background Sync:

https://ponyfoo.com/articles/backgroundsync

Cloud Functions for Firebase:

https://firebase.google.com/docs/functions/

Create and Deploy Your First Cloud Functions:

https://firebase.google.com/docs/functions/write-firebase-functions

Table 2. Realtime Database

Saving Data in Realtime Database:

https://firebase.google.com/docs/database/admin/save-data

push():

https://firebase.google.com/docs/reference/js/database#push

feed.js

var MY_FIREBASE_ID = '...'; (7)
var MY_TEST_IMAGE_URL = '...'; (8)

function sendData() { (2)
  fetch(MY_FIREBASE_ID + '.firebasedatabase.app/posts.json', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json'
    },
    body: JSON.stringify({
      id: new Date().toISOString(),
      title: titleInput.value,
      location: locationInput.value,
      image: MY_TEST_IMAGE_URL
    })
  })
    .then(function(res) {
      console.log('Sent data', res);
      updateUI();
    });
}

form.addEventListener('submit', function(event) { (6)
  event.preventDefault();

  if (titleInput.value.trim() === '' || locationInput.value.trim() === '') {
    alert('Please enter valid data!');
    return;
  }

  closeCreatePostModal();

  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    navigator.serviceWorker.ready (3)
      .then(function(sw) {
        var post = {
          id: new Date().toISOString(),
          title: titleInput.value,
          location: locationInput.value
        };
        writeData('sync-posts', post)
          .then(function () {
            return sw.sync.register('sync-new-posts')  (4)
          })
          .then(function () {
            var snackbarContainer = document.querySelector("#confirmation-toast"); (5)
            var data = {message: "Your Post was saved for syncing!"};
            snackbarContainer.MaterialSnackbar.showSnackbar(data);
          }).catch(function (err) {
            console.log(err);
          });
      });
  } else {
    sendData(); (1)
  }
});
1 отправляем данные если браузер не поддерживает background sync
2 отправка данных
3 background sync
4 регистрируем background sync event tag
5 показать toast что в оффлайне мы сохранили сообщение в idb
6 background sync происходит по сабмиту формы
7 id of my firebase
8 url of my test image

sw.js

var MY_FIREBASE_ID = '...'; (1)
var MY_TEST_IMAGE_URL = '...'; (3)

self.addEventListener('sync', function(event) {
  console.log('[Service Worker] Background syncing', event);
  if (event.tag === 'sync-new-posts') {
    console.log('[Service Worker] Syncing new Post');
    event.waitUntil(
      readAllData('sync-posts')
        .then(function(data) {
          for (var dt of data) {
            fetch(MY_FIREBASE_ID + '.firebasedatabase.app/posts.json', { (4)
              method: 'POST',
              headers: {
                'Content-Type': 'application/json',
                'Accept': 'application/json'
              },
              body: JSON.stringify({
                id: dt.id,
                title: dt.title,
                location: dt.location,
                image: MY_TEST_IMAGE_URL
              })
            })
              .then(function(res) {
                console.log('Sent data', res);
                if (res.ok) {
                  deleteItemFromData('sync-posts', dt.id); (2)
                }
              })
              .catch(function (err) {
                console.log('Error while sending data', err);
              });
          }
        })
    );
  }

});
1 id of my firebase
2 bug here - async call cannot work with loop variable
3 url of my test image
4 когда мы заменили этот url на функцию firebase storePostData возникла проблема CORS

functions

functions/index.js
const functions = require("firebase-functions");
var admin = require('firebase-admin');
var cors = require('cors')({ origin: true });

var serviceAccount = require("./pwagram-fb-key.json");
var MY_DB_URL = '...'; (1)

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
  databaseURL: MY_DB_URL
});

exports.storePostData = functions.https.onRequest((request, response) => {
    cors(request, response, function () {
        var reqBody = request.body;
        functions.logger.info('-- reqBody:', reqBody, '.'); (2)
        var newPost = {
            id: reqBody.id,
            title: reqBody.title,
            location: reqBody.location,
            image: reqBody.image
        };
        functions.logger.info('-- newPost:', newPost, '.');

        admin.database().ref('posts').push(newPost)
            .then(function () {
                response.status(201).json({ message: 'Data stored', id: request.body.id });
            })
            .catch(function (err) {
                response.status(500).json({ error: err });
            });
    });
    //   response.send("Hello from Firebase!");
});
1 url of my realtime database
2 последний элемент лога пусть будет просто строкой, иначе он может пропасть из лога :-(

10. Web Push Notifications

app.js : askForNotificationPermission

public/src/js/app.js
function displayConfirmNotification() { (5)
  var options = {
    body: "You successfully subscribed to our Notification service!"
  };
  new Notification('Successfully subscribed!', options);
}

function askForNotificationPermission() {
  Notification.requestPermission(function(result) { (4)
    console.log('User Choice', result);
    if (result !== 'granted') {
      console.log('No notification permission granted!');
    } else {
      //displayConfirmNotification(); (6)
      configurePushSub() (7)
    }
  });
}

if ('Notification' in window) { (1)
  for (var i = 0; i < enableNotificationsButtons.length; i++) {
    enableNotificationsButtons[i].style.display = 'inline-block'; (2)
    enableNotificationsButtons[i].addEventListener('click', askForNotificationPermission); (3)
  }
}
1 Can I use Web Push Notifications?
2 Сделать видимой кнопку Enable Notifications
3 Повесить обработчик на кнопку
4 Запросить разрешение на отправку пуш-нотификаций
5 Показать нотификацию, первая версия, без параметров
6 Изначально мы просто показывали нотификацию
7 А теперь подписываемся на нотификацию от сервера

app.js : displayConfirmNotification

public/src/js/app.js
function displayConfirmNotification() { (4)
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.ready
      .then(function(swreg) {
        var options = {
          body: "You successfully subscribed to our Notification service!",
          icon: "/src/images/icons/app-icon-96x96.png",
          image: "/src/images/sf-boat.jpg",
          dir: "ltr",
          lang: "en-US", // BCP 47
          vibrate: [100, 50, 200],
          badge: "/src/images/icons/app-icon-96x96.png",
          tag: "confirm-notification", (2)
          renotify: true, (3)
          actions: [
            { action: "confirm", title: "Okay", icon: "/src/images/icons/app-icon-96x96.png" },
            { action: "cancel", title: "Cancel", icon: "/src/images/icons/app-icon-96x96.png" }
          ]
        };
        swreg.showNotification('Successfully subscribed (from SW)!', options); (1)
      });
  }
}
1 Display notification from service worker
2 Notifications can be stacked on some OSes
3 Additional notifications should also produce a sound
4 Показать нотификацию, вторая версия, с параметрами

sw.js : notificationclick

public/sw.js
self.addEventListener('notificationclick', function(event) { (1)
  var notification = event.notification;
  var action = event.action;

  console.log(notification);

  if (action === 'confirm') {
    console.log('Confirm was chosen');
  } else {
    console.log(action);
  }
  notification.close(); (2)
});
1 Обработчик кнопки на нотификации. В этом случае возникает событие notificationclick
2 Закрываем нотификацию

sw.js : notificationclose

public/sw.js
self.addEventListener('notificationclose', function(event) { (1)
  console.log('Notification was closed', event);
});
1 событие notificationclose возникает при закрытии нотификации

app.js : configurePushSub

public/src/js/app.js
function configurePushSub() { (1)
  if (!('serviceWorker' in navigator)) {
    return;
  }

  var reg;
  navigator.serviceWorker.ready
    .then(function (swreg) {
      reg = swreg;
      return swreg.pushManager.getSubscription(); (2)
    })
    .then(function (sub) {
      if (sub === null) {
        // Create a new subscription
        var vapidPublicKey = "..."; (3)
        var convertedVapidPublicKey = urlBase64ToUint8Array(vapidPublicKey); (4)
        return reg.pushManager.subscribe({ (5)
          userVisibleOnly: true,
          applicationServerKey: convertedVapidPublicKey
        });
      } else {
        // We have a subscription
      }

    })
    .then(function (newSub) { (6)
      return fetch(MY_FIREBASE_ID + ".firebasedatabase.app/subscriptions.json", { (8)
        method: 'POST',
        headers: {
          "Content-Type": "application/json",
          "Accept": "application/json"
        },
        body: JSON.stringify(newSub)
      });
    })
    .then(function (res) { (7)
      if (res.ok) {
        displayConfirmNotification();
      }
    })
    .catch(function(err) {
      console.log(err);
    });
}
1 Подписываемся на нотификацию от сервера
2 Получаем подписку из pushManager
3 В папке functions мы выполнили npm install web-push
а затем npm run web-push generate-vapid-keys
В итоге получили Public Key и Private Key
4 Конвертировать ключ в Uint8Array
5 Получить подписку у браузера
6 Сохранить подписку в базе
7 Показать нотификацию, если подписка успешно сохранена
8 id of my firebase

utility.js : urlBase64ToUint8Array

public/src/js/utility.js
function urlBase64ToUint8Array(base64String) { (1)
  var padding = '='.repeat((4 - base64String.length % 4) % 4);
  var base64 = (base64String + padding)
    .replace(/\-/g, '+')
    .replace(/_/g, '/');

  var rawData = window.atob(base64);
  var outputArray = new Uint8Array(rawData.length);

  for (var i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}
1 Конвертация строки Base64 в Uint8Array

sw.js : push

public/sw.js
self.addEventListener('push', function(event) { (1)
  console.log('Push Notification received', event);

  var data = {title: "New!", content:"Something new happened!"}; (2)
  if (event.data) {
    data = JSON.parse(event.data.text()); (3)
  }

  var options = {
    body: data.content,
    icon: '/src/images/icons/app-icon-96x96.png',
    badge: '/src/images/icons/app-icon-96x96.png'
  };

  event.waitUntil(
    self.registration.showNotification(data.title, options) (4)
  );
});
1 Получаем пуш-нотификацию
2 Дефолтное сообщение
3 Сообщение с сервера
4 Показать нотификацию на устройстве

sw.js : notificationclick

public/sw.js
self.addEventListener('notificationclick', function(event) {
  var notification = event.notification;
  var action = event.action;

  console.log(notification);

  if (action === 'confirm') {
    console.log('Confirm was chosen');
    notification.close();
  } else {
    console.log(action);
    event.waitUntil(
      clients.matchAll() (3)
        .then(function (clis) {
          var client = clis.find(function (c) {
            return c.visibilityState === 'visible'
          });

          if (client !== undefined) {
            client.navigate('http://localhost:8080'); (1)
            client.focus();
          } else {
            clients.openWindow('http://localhost:8080'); (2)
          }
          notification.close();
        })
    );
  }
});
1 Клик по пуш-нотификации может перейти на указанную страницу…​
2 …​либо открыть новое окно браузера
3 Сервис-воркеру доступен список связанных с ним табов в браузере, и мы можем выбрать текущий активный

sw.js : push

public/sw.js
self.addEventListener('notificationclick', function(event) {
  var notification = event.notification;
  var action = event.action;

  console.log(notification);

  if (action === 'confirm') {
    console.log('Confirm was chosen');
    notification.close();
  } else {
    console.log(action);
    event.waitUntil(
      clients.matchAll()
        .then(function (clis) {
          var client = clis.find(function (c) {
            return c.visibilityState === 'visible'
          });

          if (client !== undefined) {
            client.navigate(notification.data.url); (2)
            client.focus();
          } else {
            clients.openWindow(notification.data.url);
          }
          notification.close();
        })
    );
  }
});

self.addEventListener('push', function(event) {
  console.log('Push Notification received', event);

  var data = {title: "New!", content:"Something new happened!", openUrl: "/"};
  if (event.data) {
    data = JSON.parse(event.data.text());
  }

  var options = {
    body: data.content,
    icon: '/src/images/icons/app-icon-96x96.png',
    badge: '/src/images/icons/app-icon-96x96.png',
    data: {
      url: data.openUrl (1)
    }
  };

  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});
1 Как вариант, мы можем передать имя страницы, на которую нужно перейти по пуш-нотификации…​
2 …​и перейти на указанную страницу по клику на нотификации

11. Native Device Features

feed.js : initializeMedia

public/src/js/feed.js
function initializeMedia() {
  if (!('mediaDevices' in navigator)) {
    navigator.mediaDevices = {};
  }

  if (!('getUserMedia' in navigator.mediaDevices)) {
    navigator.mediaDevices.getUserMedia = function (constraints) {
      var getUserMedia = navigator.webkitGetUserMedia || navigator.mozGetUserMedia; (1)

      if (!getUserMedia) {
        return Promise.reject(new Error('getUserMedia is not implemented!'));
      }

      return new Promise(function (resolve, reject) {
        getUserMedia.call(navigator, constraints, resolve, reject); (2)
      });
    };
  }

  navigator.mediaDevices.getUserMedia({video: true})
    .then(function(stream) {
      videoPlayer.srcObject = stream; (3)
      videoPlayer.style.display = 'block';
    })
    .catch(function (err) {
      imagePickerArea.style.display = 'block';
    });
}
1 Смотрим кастомную имплементацию из Safari или Firefox
2 Возвращаем Promise, который резолвит нашу функцию в контексте navigator
3 If we can use video, then send the stream to videoPlayer

feed.js : captureButton

public/src/js/feed.js
captureButton.addEventListener('click', function(event) { (1)
  canvasElement.style.display = 'block'; (2)
  videoPlayer.style.display = 'none';
  captureButton.style.display = 'none';
  var context = canvasElement.getContext('2d');
  context.drawImage(videoPlayer, 0, 0, canvas.width, videoPlayer.videoHeight / (videoPlayer.videoWidth / canvas.width)); (3)
  videoPlayer.srcObject.getVideoTracks().forEach(function(track) {
    track.stop(); (4)
  });
  picture = dataURItoBlob(canvasElement.toDataURL()); (5)
});
1 Нажатие на кнопку Capture
2 Canvas становится видимым, а videoPlayer и captureButton прячутся
3 Выводим на canvas изображение видеоплейера.
4 Останавливаем все треки видеоплейера.
5 Копируем изображение из видеоплейера в blob.

utility.js : dataURItoBlob

public/src/js/utility.js
function dataURItoBlob(dataURI) {
  var byteString = atob(dataURI.split(',')[1]);
  var mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0]
  var ab = new ArrayBuffer(byteString.length);
  var ia = new Uint8Array(ab);
  for (var i = 0; i < byteString.length; i++) {
    ia[i] = byteString.charCodeAt(i);
  }
  var blob = new Blob([ab], {type: mimeString}); (1)
  return blob;
}
1 Преобразование строки данных sв blob.

sw.js : sync

public/sw.js
self.addEventListener('sync', function(event) {
  console.log('[Service Worker] Background syncing', event);
  if (event.tag === 'sync-new-posts') {
    console.log('[Service Worker] Syncing new Posts');
    event.waitUntil(
      readAllData('sync-posts')
        .then(function(data) {
          for (var dt of data) {
            var postData = new FormData(); (1)
            postData.append("id", dt.id);
            postData.append("title", dt.title);
            postData.append("location", dt.location);
            postData.append("file", dt.picture, dt.id + ".png");

            fetch(MY_CLOUD_FUNC + '.cloudfunctions.net/storePostData', { (2)
              method: 'POST',
              body: postData
            })
              .then(function(res) {
                console.log('Sent data', res);
                if (res.ok) {
                  res.json()
                    .then(function(resData) {
                      deleteItemFromData('sync-posts', resData.id);
                    });
                }
              })
              .catch(function(err) {
                console.log('Error while sending data', err);
              });
          }

        })
    );
  }
});
1 JSON мы заменяем на FormData чтобы иметь возможность передавать файл изображения
2 MY_CLOUD_FUNC: server id for my Firebase cloud functions

functions

functions/index.js
var functions = require("firebase-functions");
var admin = require("firebase-admin");
var cors = require("cors")({ origin: true });
var webpush = require("web-push");
var fs = require("fs");
var UUID = require("uuid-v4");  (3)
var os = require("os");
var Busboy = require("busboy");  (2)
var path = require('path');

// // Create and Deploy Your First Cloud Functions
// // https://firebase.google.com/docs/functions/write-firebase-functions
//

var serviceAccount = require("./pwagram-fb-key.json");

var gcconfig = {
  projectId: "YOUR_PROJECT_ID",  (5)
  keyFilename: "pwagram-fb-key.json"
};

var gcs = require("@google-cloud/storage")(gcconfig);  (1)

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
  databaseURL: "https://YOUR_PROJECT_ID.firebaseio.com/"  (8)
});

exports.storePostData = functions.https.onRequest(function(request, response) {
  cors(request, response, function() {
    var uuid = UUID();

    const busboy = Busboy({ headers: request.headers });
    // These objects will store the values (file + fields) extracted from busboy
    let upload;
    const fields = {};

    // This callback will be invoked for each file uploaded
    busboy.on("file", (fieldname, file, info) => {
      const { filename, encoding, mimetype } = info;
      console.log(
        `File [${fieldname}] filename: ${filename}, encoding: ${encoding}, mimetype: ${mimetype}`
      );
      const filepath = path.join(os.tmpdir(), filename);
      upload = { file: filepath, type: mimetype };
      file.pipe(fs.createWriteStream(filepath));
    });

    // This will invoked on every field detected
    busboy.on('field', function(fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) {
      fields[fieldname] = val;
    });

    // This callback will be invoked after all uploaded files are saved.
    busboy.on("close", () => {
      var bucket = gcs.bucket("YOUR_PROJECT_ID.appspot.com");  (5)
      bucket.upload(  (7)
        upload.file,
        {
          uploadType: "media",
          metadata: {
            metadata: {
              contentType: upload.type,
              firebaseStorageDownloadTokens: uuid
            }
          }
        },
        function(err, uploadedFile) {
          if (!err) {
            admin
              .database()
              .ref("posts")
              .push({
                id: fields.id,
                title: fields.title,
                location: fields.location,
                image:  (6)
                  "https://firebasestorage.googleapis.com/v0/b/" +
                  bucket.name +
                  "/o/" +
                  encodeURIComponent(uploadedFile.name) +
                  "?alt=media&token=" +
                  uuid
              })
              .then(function() {
                webpush.setVapidDetails(
                  "mailto:" + MY_EMAIL,  (4)
                  MY_VAPID_PUBLIC_KEY,
                  MY_VAPID_PRIVATE_KEY
                );
                return admin
                  .database()
                  .ref("subscriptions")
                  .once("value");
              })
              .then(function(subscriptions) {
                subscriptions.forEach(function(sub) {  (9)
                  var pushConfig = {
                    endpoint: sub.val().endpoint,
                    keys: {
                      auth: sub.val().keys.auth,
                      p256dh: sub.val().keys.p256dh
                    }
                  };

                  webpush
                    .sendNotification(
                      pushConfig,
                      JSON.stringify({
                        title: "New Post",
                        content: "New Post added!",
                        openUrl: "/help"
                      })
                    )
                    .catch(function(err) {
                      console.log(err);
                    });
                });
                response
                  .status(201)
                  .json({ message: "Data stored", id: fields.id });
              })
              .catch(function(err) {
                response.status(500).json({ error: err });
              });
          } else {
            console.log(err);
          }
        }
      );
    });

    // The raw bytes of the upload will be in request.rawBody.  Send it to busboy, and get
    // a callback when it's finished.
    busboy.end(request.rawBody);
  });
});
1 npm install --save @google-cloud/storage@1.7 https://www.npmjs.com/package/@google-cloud/storage
2 npm install --save busboy https://www.npmjs.com/package/busboy
3 npm install --save uuid-v4
4 Provide MY_EMAIL, MY_VAPID_PUBLIC_KEY, MY_VAPID_PRIVATE_KEY
5 My Firebase project id here
6 Firebase Storage URL
7 Upload file to Firebase Storage
8 For me databaseURL is MY_PROJECT_ID + ".firebasedatabase.app"
9 Отправить нотификацию в каждую из найденных подписок

feed.js : imagePicker

public/src/js/feed.js
imagePicker.addEventListener('change', function(event) {
  picture = event.target.files[0]; (1)
});
1 Загруженный файл

feed.js : locationBtn

public/src/js/feed.js
locationBtn.addEventListener('click', function(event) {
  if (!('geolocation' in navigator)) {
    return;
  }

  locationBtn.style.display = 'none';
  locationLoader.style.display = 'block';

  navigator.geolocation.getCurrentPosition(function (position) { (1)
    locationBtn.style.display = 'inline';
    locationLoader.style.display = 'none';
    fetchedLocation = { lat: position.coords.latitude, lng: 0 };
    locationInput.value = 'In Munich';
    document.querySelector("#manual-location").classList.add('is-focused');

  }, function (err) {
    console.log(err);
    locationBtn.style.display = 'inline';
    locationLoader.style.display = 'none';
    alert("Couldn't get location, please enter manually!");
    fetchedLocation = { lat: null, lng: null };
  }, { timeout: 7000 })
});
1 Получаем координаты

12. Service Worker Management with Workbox

Useful Resources

Official webpage/ docs:

 https://developer.chrome.com/docs/workbox/

Workbox Github page:

 https://github.com/GoogleChrome/workbox

workbox-cli-config.js

workbox-cli-config.js
module.exports = {  (1)
  "globDirectory": "public/",
  "globPatterns": [
    "**/*.{html,ico,json,css,js}",
    "src/images/*.{png,jpg}"  (2)
  ],
  "swSrc": "public/sw-base.js",  (3)
  "swDest": "public/service-worker.js",
  "globIgnores": [
    "../workbox-cli-config.js",
    "help/**"
  ]
};
1 Сгенерировано командой workbox generate:sw в секции scripts для workbox ver.2
npm install --save-dev workbox-cli@^2
2 Кэшировать только изображения в папке images
3 Использовать шаблон сервис-воркера с командой workbox inject:manifest в секции scripts

sw-base.js

public/sw-base.js
importScripts('workbox-sw.prod.v2.1.3.js');
importScripts('/src/js/idb.js');
importScripts('/src/js/utility.js');  (4)

const workboxSW = new self.WorkboxSW();

workboxSW.router.registerRoute(/.*(?:googleapis|gstatic)\.com.*$/,  (1)
workboxSW.strategies.staleWhileRevalidate({  (2)
    cacheName: "google-fonts",
    cacheExpiration: {
        maxEntries: 3,
        maxAgeSeconds: 60 * 60 * 24 * 30  (5)
    }
}));

workboxSW.router.registerRoute("https://cdnjs.cloudflare.com/ajax/libs/material-design-lite/1.3.0/material.indigo-pink.min.css",  (6)
workboxSW.strategies.staleWhileRevalidate({
    cacheName: "material-css"
}));

workboxSW.router.registerRoute(/.*(?:firebasestorage\.googleapis)\.com.*$/,  (8)
workboxSW.strategies.staleWhileRevalidate({
    cacheName: "post-images"
}));

workboxSW.router.registerRoute(MY_FIREBASE_ID + '.firebasedatabase.app/posts.json',  (7)
function(args) {
    return fetch(args.event.request)
        .then(function (res) {
        var clonedRes = res.clone();
        clearAllData('posts')
            .then(function () {
            return clonedRes.json();
            })
            .then(function (data) {
            for (var key in data) {
                writeData('posts', data[key])
            }
            });
        return res;
        })
});

workboxSW.precache([]);  (3)
1 Для того, чтобы закэшировать шрифты и иконки от Google, нужно выбрать урлы, содержащие googleapi или gstatic
2 Применить к ним стратегию сначала из кэша, потом из сети
3 Шаблон для подстановки списка ресурсов
4 Import our scripts as well
5 Cache expiration
6 Помещаем material css в отдельный кэш
7 Обработка background sync
8 Сюда идут наши фотки
public/sw-base.js
workboxSW.router.registerRoute(function (routeData) {
    return (routeData.event.request.headers.get('accept').includes('text/html'));  (1)

}, function (args) {
    return caches.match(args.event.request)
        .then(function (response) {
            if (response) {
                return response;  (2)
            } else {
                return fetch(args.event.request)  (3)
                    .then(function (res) {
                        return caches.open('dynamic')
                            .then(function (cache) {
                                cache.put(args.event.request.url, res.clone());
                                return res;
                            })
                    })
                    .catch(function (err) {
                        return caches.match('/offline.html')  (4)
                            .then(function (res) {
                                return res;
                            });
                    });
            }
        })
});
1 В качестве критерия используем функцию. Проверяем, что кэшируются все запросы, кторые должны возвращать HTML
2 Если что-то найдено в кэше, возвращаем
3 Иначе пробуем получить страницу по сети
4 А если нет доступа к сети и страницы нет в кэше, выбрасываем сообщение об ошибке