From af96e3db5f4ec5be75afa97c63ddd6028b34fad6 Mon Sep 17 00:00:00 2001 From: N-Pex Date: Mon, 22 Dec 2025 12:31:56 +0100 Subject: [PATCH] Implement service worker offline page Also, make sure it can be translated. --- public/offline.html | 164 +++++++++++++++++ public/sw.js | 99 ++++++++++ src/App.vue | 27 +++ src/assets/translations/en.json | 5 + src/assets/translations/fi.json | 5 + src/components/MigratingView.vue | 55 ++++++ src/router/index.js | 306 +++++++++++++++++++------------ src/services/matrix.service.js | 28 ++- src/store/index.js | 27 +++ 9 files changed, 601 insertions(+), 115 deletions(-) create mode 100644 public/offline.html create mode 100644 src/components/MigratingView.vue diff --git a/public/offline.html b/public/offline.html new file mode 100644 index 0000000..0f3160a --- /dev/null +++ b/public/offline.html @@ -0,0 +1,164 @@ + + + + + + + + + + + + + +
+
Having trouble connecting?
+
Redirect to an alternate link to join the room
+ +
You are offline
+
+ + + \ No newline at end of file diff --git a/public/sw.js b/public/sw.js index 51c9f2d..bece099 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,19 @@ var periodicSyncNewMsgReminderText; +const OFFLINE_CACHE = `offline`; +const offlineCacheFiles = ['offline.html','config.json']; + +// on install we download the routes we want to cache for offline +self.addEventListener('install', evt => evt.waitUntil(caches.open(OFFLINE_CACHE).then(cache => { + console.log("SW Caching offline files"); + self.skipWaiting(); + return cache.addAll(offlineCacheFiles); +}))); + +self.addEventListener("activate", event => { + event.waitUntil(clients.claim()); +}); + // Notification click event listener self.addEventListener("notificationclick", (e) => { e.notification.close(); @@ -40,3 +54,88 @@ self.addEventListener('periodicsync', (event) => { event.waitUntil(checkNewMessages()); } }); + +self.addEventListener("fetch", (event) => { + if (event.request.mode === 'navigate') { + return event.respondWith( + fetch(event.request).catch((e) => { + console.log("OFFLINE, serve offline page", e); + return serveOfflinePage(); + })); + } else if (event.request.url.endsWith("config.json")) { + return fetch(event.request) + .then((response) => { + console.log("Caching a new version of config.json"); + let responseClone = response.clone(); + caches + .open(OFFLINE_CACHE) + .then((cache) => cache.put(event.request, responseClone)); + return response; + }) + } +}); + +async function serveOfflinePage() { + let mirrorUrl = null; + const rConfig = await caches.match("config.json", { cacheName: OFFLINE_CACHE}); + if (rConfig) { + const json = await rConfig.json(); + const mirrors = json.mirrors; + if (mirrors && Array.isArray(mirrors) && mirrors.length > 0) { + mirrorUrl = json.mirrors[Math.floor(Math.random() * mirrors.length)]; + } + } + const offlinePage = await caches.match("offline.html", { cacheName: OFFLINE_CACHE}); + if (mirrorUrl && offlinePage) { + let text = await offlinePage.text(); + text = text.replaceAll("", mirrorUrl); + + let title = undefined; + let message = undefined; + let redirect = undefined; + + await new Promise((resolve, reject) => { + var open = indexedDB.open("ServiceWorker", 1); + open.onerror = function() { + resolve(false); + } + open.onsuccess = function() { + // Start a new transaction + var db = open.result; + var tx = db.transaction("offline_strings", "readonly"); + var store = tx.objectStore("offline_strings"); + + var get1 = store.get("offline_title"); + var get2 = store.get("offline_message"); + var get3 = store.get("offline_redirect"); + + get1.onsuccess = function() { + title = get1.result.translation; + }; + get2.onsuccess = function() { + message = get2.result.translation; + }; + get3.onsuccess = function() { + redirect = get3.result.translation; + }; + + // Close the db when the transaction is done + tx.oncomplete = function() { + db.close(); + resolve(true); + }; + } + }); + if (title) { + text = text.replaceAll(/(.*?)/g, title); + } + if (message) { + text = text.replaceAll(/(.*?)/g, message); + } + if (redirect) { + text = text.replaceAll(/(.*?)/g, redirect); + } + return new Response(text, { headers: {"content-type": "text/html"}}); + } + throw new Error("Offline"); +} \ No newline at end of file diff --git a/src/App.vue b/src/App.vue index 9f46442..16dfc33 100644 --- a/src/App.vue +++ b/src/App.vue @@ -121,6 +121,33 @@ export default { // Set language this.$i18n.locale = this.$store.state.language || "en"; + + // Store offline strings. + // TODO - Better way to to this? The service worker needs to be able to get these, OR we need multiple translated version of offline.html + // + // Inspired by https://gist.github.com/JamesMessinger/a0d6389a5d0e3a24814b + var indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB || window.shimIndexedDB; + var open = indexedDB.open("ServiceWorker", 1); + // Create the schema + open.onupgradeneeded = () => { + var db = open.result; + var store = db.createObjectStore("offline_strings", {keyPath: "id"}); + }; + open.onsuccess = () => { + // Start a new transaction + var db = open.result; + var tx = db.transaction("offline_strings", "readwrite"); + var store = tx.objectStore("offline_strings"); + + store.put({id: "offline_title", translation: this.$t("offline.offline_title")}); + store.put({id: "offline_message", translation: this.$t("offline.offline_message")}); + store.put({id: "offline_redirect", translation: this.$t("offline.offline_redirect")}); + + // Close the db when the transaction is done + tx.oncomplete = function() { + db.close(); + }; + } }, showNotification() { if(document.visibilityState === "hidden") { diff --git a/src/assets/translations/en.json b/src/assets/translations/en.json index 2b3115d..891fb3d 100644 --- a/src/assets/translations/en.json +++ b/src/assets/translations/en.json @@ -541,5 +541,10 @@ "generated_with_ai": "Generated with AI.", "modified": "Modified.", "older_than_n_months": "Older than {n} months." + }, + "offline": { + "offline_title": "Having trouble connecting?", + "offline_message": "Redirect to an alternate link to join the room", + "offline_redirect": "Redirect me" } } diff --git a/src/assets/translations/fi.json b/src/assets/translations/fi.json index a017ba7..4068a93 100644 --- a/src/assets/translations/fi.json +++ b/src/assets/translations/fi.json @@ -259,5 +259,10 @@ }, "delete_post": { "confirm_text_desc": "Tätä toimintoa ei voi perua." + }, + "offline": { + "offline_title": "Ongelmia yhdistämisessä?", + "offline_message": "Siirry vaihtoehtoiseen linkkiin liittyäksesi huoneeseen", + "offline_redirect": "Siirrä minut" } } diff --git a/src/components/MigratingView.vue b/src/components/MigratingView.vue new file mode 100644 index 0000000..dc720bd --- /dev/null +++ b/src/components/MigratingView.vue @@ -0,0 +1,55 @@ + + + + \ No newline at end of file diff --git a/src/router/index.js b/src/router/index.js index 863587f..513a22d 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -1,126 +1,141 @@ -import Home from '../components/Home.vue' -import Chat from '../components/Chat.vue' -import Join from '../components/Join.vue' -import Login from '../components/Login.vue' -import Profile from '../components/Profile.vue' -import CreateRoom from '../components/CreateRoom.vue' -import GetLink from '../components/GetLink.vue' -import Create from '../components/Create.vue' -import CreateChannel from '../components/CreateChannel.vue' -import CreateFileDrop from '../components/CreateFileDrop.vue' -import User from '../models/user' -import util from '../plugins/utils' -import { createRouter, createWebHashHistory } from 'vue-router' -import { defineAsyncComponent } from 'vue' +import Home from "../components/Home.vue"; +import Chat from "../components/Chat.vue"; +import Join from "../components/Join.vue"; +import Login from "../components/Login.vue"; +import Profile from "../components/Profile.vue"; +import CreateRoom from "../components/CreateRoom.vue"; +import GetLink from "../components/GetLink.vue"; +import Create from "../components/Create.vue"; +import CreateChannel from "../components/CreateChannel.vue"; +import CreateFileDrop from "../components/CreateFileDrop.vue"; +import User from "../models/user"; +import util from "../plugins/utils"; +import { createRouter, createWebHashHistory } from "vue-router"; +import { defineAsyncComponent } from "vue"; +import { STORE_KEY_SETTINGS, STORE_KEY_USER } from "../store"; const routes = [ { - path: '/', - name: 'Home', - component: Home + path: "/", + name: "Home", + component: Home, }, { - path: '/room/:join(join/)?:roomId?', - name: 'Chat', + path: "/room/:join(join/)?:roomId?", + name: "Chat", component: Chat, meta: { includeRoom: true, - includeFavicon: true + includeFavicon: true, }, }, { - path: '/info', - name: 'RoomInfo', - component: () => import('../components/RoomInfo.vue'), + path: "/info", + name: "RoomInfo", + component: () => import("../components/RoomInfo.vue"), props: true, meta: { - title: 'Info', + title: "Info", includeRoom: true, - includeFavicon: true - } + includeFavicon: true, + }, }, { - path: '/profile', - name: 'Profile', + path: "/profile", + name: "Profile", component: Profile, meta: { - title: 'Profile', - includeFavicon: true - } + title: "Profile", + includeFavicon: true, + }, }, { - path: '/createroom', - name: 'CreateRoom', + path: "/createroom", + name: "CreateRoom", component: CreateRoom, meta: { - title: 'Create room' - } + title: "Create room", + }, }, { - path: '/getlink', - name: 'GetLink', + path: "/getlink", + name: "GetLink", component: GetLink, }, { - path: '/create', - name: 'Create', + path: "/create", + name: "Create", component: Create, }, { - path: '/createchannel', - name: 'CreateChannel', + path: "/createchannel", + name: "CreateChannel", component: CreateChannel, }, { - path: '/createfiledrop', - name: 'CreateFileDrop', + path: "/createfiledrop", + name: "CreateFileDrop", component: CreateFileDrop, }, { - path: '/login', - name: 'Login', + path: "/login", + name: "Login", component: Login, - props: true + props: true, }, { - path: '/join/:join(join/)?:roomId?', - name: 'Join', + path: "/join/:join(join/)?:roomId?", + name: "Join", component: Join, - props: route => { + props: (route) => { return { - roomNeedsKnock: (route.query.knock ? true : false), - roomDisplayName: route.query.roomName - } - } + roomNeedsKnock: route.query.knock ? true : false, + roomDisplayName: route.query.roomName, + }; + }, }, { - path: '/user/:userId?', - name: 'User', - component: Join + path: "/user/:userId?", + name: "User", + component: Join, }, { - path: '/invite/:roomId?', - name: 'Invite', - component: defineAsyncComponent(() => import('../components/Invite.vue')), + path: "/invite/:roomId?", + name: "Invite", + component: defineAsyncComponent(() => import("../components/Invite.vue")), meta: { - title: 'Add Friends' - } + title: "Add Friends", + }, }, { - path: '/goodbye', - name: 'Goodbye', - component: defineAsyncComponent(() => import('../components/QuoteView.vue')), - props: true - } -] + path: "/goodbye", + name: "Goodbye", + component: defineAsyncComponent(() => import("../components/QuoteView.vue")), + props: true, + }, + { + path: "/migrating", + name: "Migrating", + component: defineAsyncComponent(() => import("../components/MigratingView.vue")), + props: false, + }, +]; const router = createRouter({ history: createWebHashHistory(), - routes: routes + routes: routes, }); router.beforeEach((to, from, next) => { - const publicPages = ['/login', '/createroom', '/getlink', '/create', '/createchannel', '/createfiledrop']; + const publicPages = [ + "/login", + "/createroom", + "/getlink", + "/create", + "/createchannel", + "/createfiledrop", + "/migrating", + ]; var authRequired = !publicPages.includes(to.path); const loggedIn = router.app.$store.state.auth.user; @@ -129,14 +144,46 @@ router.beforeEach((to, from, next) => { const lang = to.query.lang; // Check if valid translation if (router.app.$i18n.messages[lang]) { - router.app.$store.commit('setLanguage', lang); + router.app.$store.commit("setLanguage", lang); if (router.app.$i18n) { router.app.$i18n.locale = to.query.lang; } } } - if (to.name == 'Chat' || to.name == 'Join') { + if (to.query && to.query.migrate) { + try { + if (window.opener) { + console.log("Start listening for migration messages"); + window.addEventListener("message", function (e) { + // if (e.origin !== origin) { // TODO - What should we check here? + // return + // } + try { + const data = JSON.parse(e.data); + if (data !== null) { + router.app.$store + .dispatch("migrate", data) + .then(() => { + console.log("Migration success"); + }) + .catch((error) => { + console.log("Migration error", error); + }) + .finally(() => { + window.opener.postMessage(JSON.stringify({ cmd: "migrationDone" }), "*"); + router.app.$navigation.push({ name: "Home" }, -1); + }); + } + } catch (error) {} + }); + window.opener.postMessage(JSON.stringify({ cmd: "getMigrationData" }), "*"); + } + return next("/migrating"); + } catch (error) {} + } + + if (to.name == "Chat" || to.name == "Join") { if (!to.params.roomId && to.hash) { // Public rooms start with '#', confuses the router. If hash but no roomId param, set it. to.params.roomId = to.hash; @@ -147,62 +194,84 @@ router.beforeEach((to, from, next) => { //Invite to public room authRequired = false; } - } else if (to.name == 'Home') { + } else if (to.name == "Home") { if (to.params.roomId !== undefined) { const roomId = to.params.roomId ? util.sanitizeRoomId(to.params.roomId) : null; router.app.$matrix.setCurrentRoomId(roomId); } - } else if (to.name == 'User') { + } else if (to.name == "User") { if (to.params.userId) { let roomId = util.sanitizeUserId(to.params.userId); if (roomId && !roomId.startsWith("@")) { // Not a full username. Assume local name on this server. - return router.app.$config.load().then((config) => { - const domain = config.defaultMatrixDomainPart; - if (!domain) throw new Error("No domain part for user invite!"); - roomId = "@" + roomId + ":" + domain; - router.app.$matrix.setCurrentRoomId(roomId); - }).catch(err => console.error(err)).finally(() => next()); + return router.app.$config + .load() + .then((config) => { + const domain = config.defaultMatrixDomainPart; + if (!domain) throw new Error("No domain part for user invite!"); + roomId = "@" + roomId + ":" + domain; + router.app.$matrix.setCurrentRoomId(roomId); + }) + .catch((err) => console.error(err)) + .finally(() => next()); } else { router.app.$matrix.setCurrentRoomId(roomId); authRequired = false; } } - } else if (to.name == 'Invite') { + } else if (to.name == "Invite") { if (to.params.roomId) { const roomId = util.sanitizeRoomId(to.params.roomId); router.app.$matrix.setCurrentRoomId(roomId); } - } else if (to.name == 'CreateRoom') { - return router.app.$config.load().then((config) => { - if (config.hide_add_room_on_home || !config.roomTypes.includes("group_chat")) { - next('/'); - } else { - next(); - } - }).catch(err => { console.error(err); next('/'); }); - } else if (to.name == 'CreateChannel') { - return router.app.$config.load().then((config) => { - if (!config.roomTypes.includes("channel")) { - next('/'); - } else { - next(); - } - }).catch(err => { console.error(err); next('/'); }); - } else if (to.name == 'CreateFileDrop') { - return router.app.$config.load().then((config) => { - if (!config.roomTypes.includes("file_drop")) { - next('/'); - } else { - next(); - } - }).catch(err => { console.error(err); next('/'); }); + } else if (to.name == "CreateRoom") { + return router.app.$config + .load() + .then((config) => { + if (config.hide_add_room_on_home || !config.roomTypes.includes("group_chat")) { + next("/"); + } else { + next(); + } + }) + .catch((err) => { + console.error(err); + next("/"); + }); + } else if (to.name == "CreateChannel") { + return router.app.$config + .load() + .then((config) => { + if (!config.roomTypes.includes("channel")) { + next("/"); + } else { + next(); + } + }) + .catch((err) => { + console.error(err); + next("/"); + }); + } else if (to.name == "CreateFileDrop") { + return router.app.$config + .load() + .then((config) => { + if (!config.roomTypes.includes("file_drop")) { + next("/"); + } else { + next(); + } + }) + .catch((err) => { + console.error(err); + next("/"); + }); } // trying to access a restricted page + not logged in // redirect to login page if (authRequired && !loggedIn) { - next('/login'); + next("/login"); } else { next(); } @@ -225,12 +294,21 @@ router.getRoomLink = function (alias, roomId, roomName, mode, autojoin, knock, m const autoJoinSegment = autojoin ? "join/" : ""; let queryString = ""; if (Object.entries(params).length > 0) { - queryString = "?" + Object.entries(params) - .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) - .join('&') + queryString = + "?" + + Object.entries(params) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join("&"); } - return (mirror ? (window.location.protocol + "//" + mirror) : window.location.origin) + window.location.pathname + "#/room/" + autoJoinSegment + encodeURIComponent(util.sanitizeRoomId(alias || roomId)) + queryString; -} + return ( + (mirror ? window.location.protocol + "//" + mirror : window.location.origin) + + window.location.pathname + + "#/room/" + + autoJoinSegment + + encodeURIComponent(util.sanitizeRoomId(alias || roomId)) + + queryString + ); +}; router.getDMLink = function (user, config, mirror) { let userId = user.user_id; @@ -238,7 +316,9 @@ router.getDMLink = function (user, config, mirror) { // Using default server, don't include it in the link userId = User.localPart(user.user_id); } - return `${(mirror ? (window.location.protocol + "//" + mirror) : window.location.origin) + window.location.pathname}#/user/${encodeURIComponent(userId)}` -} + return `${ + (mirror ? window.location.protocol + "//" + mirror : window.location.origin) + window.location.pathname + }#/user/${encodeURIComponent(userId)}`; +}; -export default router +export default router; diff --git a/src/services/matrix.service.js b/src/services/matrix.service.js index cbe284c..5f21070 100644 --- a/src/services/matrix.service.js +++ b/src/services/matrix.service.js @@ -141,7 +141,27 @@ export default { } return this.legacyCryptoStore; }, - login(user, registrationFlowHandler, createUser = false) { + migrate(user) { + return util.getMatrixBaseUrl(user, this.$config) + .then((baseUrl) => { + console.log("Create temp client", user); + const tempMatrixClient = sdk.createClient({ + baseUrl: baseUrl, + idBaseUrl: this.$config.identityServer, + }); + user.device_id = tempMatrixClient.getSessionId(); + return this.refreshAccessToken(tempMatrixClient, user.refresh_token); + }).then(() => { + console.log("Refresh done, normal login"); + return this.login(user, undefined, false, true); + }); + }, + login(user, registrationFlowHandler, createUser = false, isMigration = false) { + if (!isMigration && window.location.href.includes("migrate=1")) { + console.log("Migrate, so no login!"); + return Promise.reject("Migrating, so no login"); + } + return util.getMatrixBaseUrl(user, this.$config).then((baseUrl) => { const tempMatrixClient = sdk.createClient({ baseUrl: baseUrl, @@ -510,12 +530,16 @@ export default { }, onSessionRefresh(refreshToken) { + return this.refreshAccessToken(this.matrixClient, refreshToken); + }, + + refreshAccessToken(matrixClient, refreshToken) { if (this.tokenRefreshPromise) { return this.tokenRefreshPromise; } const now = Date.now(); this.tokenRefreshPromise = - this.matrixClient.http.request(sdk.Method.Post, "/refresh", undefined, { refresh_token: refreshToken}, { prefix: sdk.ClientPrefix.V3, inhibitLogoutEmit: true }).then((result) => { + matrixClient.http.request(sdk.Method.Post, "/refresh", undefined, { refresh_token: refreshToken}, { prefix: sdk.ClientPrefix.V3, inhibitLogoutEmit: true }).then((result) => { // Store new one! var user = this.$store.state.auth.user; user.access_token = result.access_token; diff --git a/src/store/index.js b/src/store/index.js index 1b3c597..16fc704 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -136,6 +136,33 @@ const store = createStore({ this.$matrix.logout(); commit('logout'); }, + migrate({ state }, migrationData) { + if (migrationData && migrationData.type) { + let storage = migrationData.type == "session" ? sessionStorage : localStorage; + if (migrationData.settings) { + try { + const settings = JSON.parse(migrationData.settings); + storage.setItem(STORE_KEY_SETTINGS, migrationData.settings); + state.useLocalStorage = migrationData.type != "session"; + } catch (error) { + console.error("Failed to migrate settings", error); + } + } + if (migrationData.user) { + const user = JSON.parse(migrationData.user); + state.auth.user = user; + return this.$matrix.migrate(user).then( + (user) => { + return Promise.resolve(user); + }, + (error) => { + return Promise.reject(error); + } + ); + } + } + return Promise.reject("Migration error"); + } }, getters: { storage: state => {