瀏覽代碼

Add basic PWA offline page

- Remove feed_icons cache because it's causing more problems that it solve.
- Add basic offline mode when using the service worker.
- Starting in Chrome 93, offline mode is going to be a requirement to install the PWA.

https://developer.chrome.com/blog/improved-pwa-offline-detection/#enforcement-starting-chrome-93-august-2021
Frédéric Guillot 5 年之前
父節點
當前提交
8a812cd8ec

+ 3 - 0
locale/translations/de_DE.json

@@ -200,6 +200,9 @@
     "page.api_keys.table.actions": "Aktionen",
     "page.api_keys.never_used": "Nie benutzt",
     "page.new_api_key.title": "Neuer API-Schlüssel",
+    "page.offline.title": "Offline-Modus",
+    "page.offline.message": "Du bist offline",
+    "page.offline.refresh_page": "Versuchen Sie, die Seite zu aktualisieren",
     "alert.no_shared_entry": "Es existieren derzeit keine geteilten Artikel.",
     "alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.",
     "alert.no_category": "Es ist keine Kategorie vorhanden.",

+ 3 - 0
locale/translations/en_US.json

@@ -200,6 +200,9 @@
     "page.api_keys.table.actions": "Actions",
     "page.api_keys.never_used": "Never Used",
     "page.new_api_key.title": "New API Key",
+    "page.offline.title": "Offline Mode",
+    "page.offline.message": "You are offline",
+    "page.offline.refresh_page": "Try to refresh the page",
     "alert.no_shared_entry": "There is no shared entry.",
     "alert.no_bookmark": "There is no bookmark at the moment.",
     "alert.no_category": "There is no category.",

+ 3 - 0
locale/translations/es_ES.json

@@ -200,6 +200,9 @@
     "page.api_keys.table.actions": "Acciones",
     "page.api_keys.never_used": "Nunca usado",
     "page.new_api_key.title": "Nueva clave API",
+    "page.offline.title": "Modo offline",
+    "page.offline.message": "Estas desconectado",
+    "page.offline.refresh_page": "Intenta actualizar la página",
     "alert.no_shared_entry": "No hay entrada compartida.",
     "alert.no_bookmark": "No hay marcador en este momento.",
     "alert.no_category": "No hay categoría.",

+ 3 - 0
locale/translations/fr_FR.json

@@ -200,6 +200,9 @@
     "page.api_keys.table.actions": "Actions",
     "page.api_keys.never_used": "Jamais utilisé",
     "page.new_api_key.title": "Nouvelle clé d'API",
+    "page.offline.title": "Mode Hors-Ligne",
+    "page.offline.message": "Vous n'êtes pas connecté",
+    "page.offline.refresh_page": "Essayez de rafraîchir la page",
     "alert.no_shared_entry": "Il n'y a pas d'article partagé.",
     "alert.no_bookmark": "Il n'y a aucun favoris pour le moment.",
     "alert.no_category": "Il n'y a aucune catégorie.",

+ 3 - 0
locale/translations/it_IT.json

@@ -200,6 +200,9 @@
     "page.api_keys.table.actions": "Azioni",
     "page.api_keys.never_used": "Mai usato",
     "page.new_api_key.title": "Nuova chiave API",
+    "page.offline.title": "Modalità offline",
+    "page.offline.message": "Sei offline",
+    "page.offline.refresh_page": "Prova ad aggiornare la pagina",
     "alert.no_shared_entry": "Non ci sono voci condivise.",
     "alert.no_bookmark": "Nessun preferito disponibile.",
     "alert.no_category": "Nessuna categoria disponibile.",

+ 3 - 0
locale/translations/ja_JP.json

@@ -200,6 +200,9 @@
     "page.api_keys.table.actions": "アクション",
     "page.api_keys.never_used": "使われたことがない",
     "page.new_api_key.title": "新しいAPIキー",
+    "page.offline.title": "オフラインモード",
+    "page.offline.message": "オフラインです",
+    "page.offline.refresh_page": "ページを更新してみてください",
     "alert.no_shared_entry": "共有エントリはありません。",
     "alert.no_bookmark": "現在星付きはありません。",
     "alert.no_category": "カテゴリが存在しません。",

+ 3 - 0
locale/translations/nl_NL.json

@@ -200,6 +200,9 @@
     "page.api_keys.table.actions": "Acties",
     "page.api_keys.never_used": "Nooit gebruikt",
     "page.new_api_key.title": "Nieuwe API-sleutel",
+    "page.offline.title": "Offline modus",
+    "page.offline.message": "Je bent offline",
+    "page.offline.refresh_page": "Probeer de pagina te vernieuwen",
     "alert.no_shared_entry": "Er is geen gedeelde toegang.",
     "alert.no_bookmark": "Er zijn op dit moment geen favorieten.",
     "alert.no_category": "Er zijn geen categorieën.",

+ 3 - 0
locale/translations/pl_PL.json

@@ -202,6 +202,9 @@
     "page.api_keys.table.actions": "Działania",
     "page.api_keys.never_used": "Nigdy nie używany",
     "page.new_api_key.title": "Nowy klucz API",
+    "page.offline.title": "Tryb offline",
+    "page.offline.message": "Jesteś odłączony od sieci",
+    "page.offline.refresh_page": "Spróbuj odświeżyć stronę",
     "alert.no_shared_entry": "Brak wspólnego wpisu.",
     "alert.no_bookmark": "Obecnie nie ma żadnych zakładek.",
     "alert.no_category": "Nie ma żadnej kategorii!",

+ 3 - 0
locale/translations/pt_BR.json

@@ -200,6 +200,9 @@
     "page.api_keys.table.actions": "Ações",
     "page.api_keys.never_used": "Nunca usado",
     "page.new_api_key.title": "Nova chave de API",
+    "page.offline.title": "Modo offline",
+    "page.offline.message": "Você está offline",
+    "page.offline.refresh_page": "Tente atualizar a página",
     "alert.no_shared_entry": "Não há itens compartilhados.",
     "alert.no_bookmark": "Não há favorito neste momento.",
     "alert.no_category": "Não há categoria.",

+ 3 - 0
locale/translations/ru_RU.json

@@ -202,6 +202,9 @@
     "page.api_keys.table.actions": "Действия",
     "page.api_keys.never_used": "Никогда не использовался",
     "page.new_api_key.title": "Новый API-ключ",
+    "page.offline.title": "Автономный режим",
+    "page.offline.message": "Ты не в сети",
+    "page.offline.refresh_page": "Попробуйте обновить страницу",
     "alert.no_shared_entry": "Общедоступные записи отсутствуют.",
     "alert.no_bookmark": "Избранное отсутствует.",
     "alert.no_category": "Категории отсутствуют.",

+ 3 - 0
locale/translations/zh_CN.json

@@ -198,6 +198,9 @@
     "page.api_keys.table.actions": "操作",
     "page.api_keys.never_used": "没用过",
     "page.new_api_key.title": "新的API密钥",
+    "page.offline.title": "离线模式",
+    "page.offline.message": "您离线",
+    "page.offline.refresh_page": "尝试刷新页面",
     "alert.no_shared_entry": "没有共享条目。",
     "alert.no_bookmark": "目前没有书签",
     "alert.no_category": "目前没有分类",

+ 19 - 0
template/engine.go

@@ -24,6 +24,9 @@ var commonTemplateFiles embed.FS
 //go:embed templates/views/*.html
 var viewTemplateFiles embed.FS
 
+//go:embed templates/standalone/*.html
+var standaloneTemplateFiles embed.FS
+
 // Engine handles the templating system.
 type Engine struct {
 	templates map[string]*template.Template
@@ -75,6 +78,22 @@ func (e *Engine) ParseTemplates() error {
 		e.templates[templateName] = template.Must(template.New("main").Funcs(e.funcMap.Map()).Parse(templateContents.String()))
 	}
 
+	dirEntries, err = standaloneTemplateFiles.ReadDir("templates/standalone")
+	if err != nil {
+		return err
+	}
+
+	for _, dirEntry := range dirEntries {
+		templateName := dirEntry.Name()
+		fileData, err := standaloneTemplateFiles.ReadFile("templates/standalone/" + dirEntry.Name())
+		if err != nil {
+			return err
+		}
+
+		logger.Debug("[Template] Parsing: %s", templateName)
+		e.templates[templateName] = template.Must(template.New("base").Funcs(e.funcMap.Map()).Parse(string(fileData)))
+	}
+
 	return nil
 }
 

+ 13 - 0
template/templates/standalone/offline.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html>
+    <head>
+        <meta charset="utf-8">
+        <title>{{ t "page.offline.title" }} - Miniflux</title>
+        <meta name="viewport" content="width=device-width, initial-scale=1.0">
+        <meta name="color-scheme" content="dark light">
+        <meta name="theme-color" content="{{ theme_color .theme }}">
+    </head>
+    <body>
+        <p>{{ t "page.offline.message" }} ‐ <a href="{{ route "unread" }}">{{ t "page.offline.refresh_page" }}</a>.</p>
+    </body>
+</html>

+ 2 - 1
ui/middleware.go

@@ -142,7 +142,8 @@ func (m *middleware) isPublicRoute(r *http.Request) bool {
 		"webManifest",
 		"robots",
 		"sharedEntry",
-		"healthcheck":
+		"healthcheck",
+		"offline":
 		return true
 	default:
 		return false

+ 20 - 0
ui/offline.go

@@ -0,0 +1,20 @@
+// Copyright 2021 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package ui // import "miniflux.app/ui"
+
+import (
+	"net/http"
+
+	"miniflux.app/http/request"
+	"miniflux.app/http/response/html"
+	"miniflux.app/ui/session"
+	"miniflux.app/ui/view"
+)
+
+func (h *handler) showOfflinePage(w http.ResponseWriter, r *http.Request) {
+	sess := session.New(h.store, request.SessionID(r))
+	view := view.New(h.tpl, r, sess)
+	html.OK(w, r, view.Render("offline"))
+}

+ 1 - 1
ui/static/js/.jshintrc

@@ -1,3 +1,3 @@
 {
-  "esversion": 6
+  "esversion": 8
 }

+ 39 - 9
ui/static/js/service_worker.js

@@ -1,14 +1,44 @@
+
+// Incrementing OFFLINE_VERSION will kick off the install event and force
+// previously cached resources to be updated from the network.
+const OFFLINE_VERSION = 1;
+const CACHE_NAME = "offline";
+
+self.addEventListener("install", (event) => {
+    event.waitUntil(
+        (async () => {
+            const cache = await caches.open(CACHE_NAME);
+
+            // Setting {cache: 'reload'} in the new request will ensure that the
+            // response isn't fulfilled from the HTTP cache; i.e., it will be from
+            // the network.
+            await cache.add(new Request(OFFLINE_URL, { cache: "reload" }));
+        })()
+    );
+
+    // Force the waiting service worker to become the active service worker.
+    self.skipWaiting();
+});
+
 self.addEventListener("fetch", (event) => {
-    if (event.request.url.includes("/feed/icon/")) {
+    // We proxify requests through fetch() only if we are offline because it's slower.
+    if (navigator.onLine === false && event.request.mode === "navigate") {
         event.respondWith(
-            caches.open("feed_icons").then((cache) => {
-                return cache.match(event.request).then((response) => {
-                    return response || fetch(event.request).then((response) => {
-                        cache.put(event.request, response.clone());
-                        return response;
-                    });
-                });
-            })
+            (async () => {
+                try {
+                    // Always try the network first.
+                    const networkResponse = await fetch(event.request);
+                    return networkResponse;
+                } catch (error) {
+                    // catch is only triggered if an exception is thrown, which is likely
+                    // due to a network error.
+                    // If fetch() returns a valid HTTP response with a response code in
+                    // the 4xx or 5xx range, the catch() will NOT be called.
+                    const cache = await caches.open(CACHE_NAME);
+                    const cachedResponse = await cache.match(OFFLINE_URL);
+                    return cachedResponse;
+                }
+            })()
         );
     }
 });

+ 1 - 2
ui/static/static.go

@@ -127,8 +127,7 @@ func GenerateJavascriptBundles() error {
 	}
 
 	var prefixes = map[string]string{
-		"app":            "(function(){'use strict';",
-		"service-worker": "'use strict';",
+		"app": "(function(){'use strict';",
 	}
 
 	var suffixes = map[string]string{

+ 10 - 1
ui/static_javascript.go

@@ -5,12 +5,14 @@
 package ui // import "miniflux.app/ui"
 
 import (
+	"fmt"
 	"net/http"
 	"time"
 
 	"miniflux.app/http/request"
 	"miniflux.app/http/response"
 	"miniflux.app/http/response/html"
+	"miniflux.app/http/route"
 	"miniflux.app/ui/static"
 )
 
@@ -23,8 +25,15 @@ func (h *handler) showJavascript(w http.ResponseWriter, r *http.Request) {
 	}
 
 	response.New(w, r).WithCaching(etag, 48*time.Hour, func(b *response.Builder) {
+		contents := static.JavascriptBundles[filename]
+
+		if filename == "service-worker" {
+			variables := fmt.Sprintf(`const OFFLINE_URL="%s";`, route.Path(h.router, "offline"))
+			contents = append([]byte(variables)[:], contents[:]...)
+		}
+
 		b.WithHeader("Content-Type", "text/javascript; charset=utf-8")
-		b.WithBody(static.JavascriptBundles[filename])
+		b.WithBody(contents)
 		b.Write()
 	})
 }

+ 3 - 0
ui/ui.go

@@ -145,6 +145,9 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
 	uiRouter.HandleFunc("/oauth2/{provider}/redirect", handler.oauth2Redirect).Name("oauth2Redirect").Methods(http.MethodGet)
 	uiRouter.HandleFunc("/oauth2/{provider}/callback", handler.oauth2Callback).Name("oauth2Callback").Methods(http.MethodGet)
 
+	// Offline page
+	uiRouter.HandleFunc("/offline", handler.showOfflinePage).Name("offline").Methods(http.MethodGet)
+
 	// Authentication pages.
 	uiRouter.HandleFunc("/login", handler.checkLogin).Name("checkLogin").Methods(http.MethodPost)
 	uiRouter.HandleFunc("/logout", handler.logout).Name("logout").Methods(http.MethodGet)