Implement service worker offline page

Also, make sure it can be translated.
This commit is contained in:
N-Pex 2025-12-22 12:31:56 +01:00
parent 7cec56fb50
commit af96e3db5f
9 changed files with 601 additions and 115 deletions

164
public/offline.html Normal file
View file

@ -0,0 +1,164 @@
<html>
<head>
<meta charset="UTF-8">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
<script lang="javascript">
const STORE_KEY_USER = `convene_${window.location.hostname}_user`;
const STORE_KEY_SETTINGS = `convene_${window.location.hostname}_settings`;
let windowProxy = null;
function updateNetworkState() {
if (navigator.onLine) {
document.body.classList.remove("offline");
} else {
document.body.classList.add("offline");
}
}
window.addEventListener("load", (event) => {
updateNetworkState();
});
window.addEventListener("offline", (e) => {
updateNetworkState();
});
window.addEventListener("online", (e) => {
updateNetworkState();
});
function go() {
if (navigator.onLine) {
windowProxy = window.open("<!--MIRROR_URL-->/#/?migrate=1", "_blank");
}
}
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 && data.cmd == "getMigrationData") {
let migrationInfo = {
};
if (window.localStorage.getItem(STORE_KEY_USER)) {
migrationInfo.type = "local";
migrationInfo.user = window.localStorage.getItem(STORE_KEY_USER);
migrationInfo.settings = window.localStorage.getItem(STORE_KEY_SETTINGS);
} else {
migrationInfo.type = "session";
migrationInfo.user = window.sessionStorage.getItem(STORE_KEY_USER);
migrationInfo.settings = window.sessionStorage.getItem(STORE_KEY_SETTINGS);
}
if (migrationInfo.user) {
try {
const user = JSON.parse(migrationInfo.user);
delete user.access_token;
delete user.device_id;
migrationInfo.user = JSON.stringify(user);
} catch (error) { }
}
event.source.postMessage(JSON.stringify(migrationInfo), event.origin);
} else if (data !== null && data.cmd == "migrationDone") {
if (windowProxy) {
windowProxy.close();
window.location.href = "<!--MIRROR_URL-->/#/";
}
}
} catch (error) {
}
});
</script>
<style>
body {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.title {
font-family: "Inter", sans-serif;
font-weight: 700;
font-style: Bold;
font-size: 16px;
leading-trim: NONE;
line-height: 125%;
letter-spacing: 0.4px;
text-align: center;
vertical-align: middle;
}
.message {
font-family: "Inter", sans-serif;
font-weight: 400;
font-style: normal;
font-size: 16px;
leading-trim: NONE;
line-height: 125%;
letter-spacing: 0.4px;
text-align: center;
vertical-align: middle;
}
button {
margin-top: 16px;
height: 40px;
border: 1px solid black;
border-radius: 20px;
background-color: white;
padding: 8px 16px;
font-family: "Poppins", sans-serif;
font-weight: 600;
font-style: SemiBold;
font-size: 14px;
leading-trim: NONE;
line-height: 18px;
letter-spacing: 0.34px;
text-align: center;
vertical-align: middle;
text-transform: uppercase;
}
button:hover {
opacity: 0.8;
cursor: pointer;
}
.offline-message {
margin-top: 40px;
visibility: hidden;
}
.offline .offline-message {
visibility: visible;
}
</style>
</head>
<body>
<div class="container">
<div class="title"><!--OFFLINE_TITLE_START-->Having trouble connecting?<!--OFFLINE_TITLE_END--></div>
<div class="message"><!--OFFLINE_MESSAGE_START-->Redirect to an alternate link to join the room<!--OFFLINE_MESSAGE_END--></div>
<button onclick="go()"><!--OFFLINE_REDIRECT_START-->Redirect me<!--OFFLINE_REDIRECT_END--></button>
<div class="offline-message">You are offline</div>
</div>
</body>
</html>

View file

@ -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("<!--MIRROR_URL-->", 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(/<!--OFFLINE_TITLE_START-->(.*?)<!--OFFLINE_TITLE_END-->/g, title);
}
if (message) {
text = text.replaceAll(/<!--OFFLINE_MESSAGE_START-->(.*?)<!--OFFLINE_MESSAGE_END-->/g, message);
}
if (redirect) {
text = text.replaceAll(/<!--OFFLINE_REDIRECT_START-->(.*?)<!--OFFLINE_REDIRECT_END-->/g, redirect);
}
return new Response(text, { headers: {"content-type": "text/html"}});
}
throw new Error("Offline");
}

View file

@ -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") {

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -0,0 +1,55 @@
<template>
<div>
<transition name="slow-fade">
<div
class="text-center d-flex flex-column goodbye-wrapper"
>
<div class="quote">Migrating...</div>
</div>
</transition>
</div>
</template>
<script>
export default {
name: "MigratingView",
data() {
return {
};
},
};
</script>
<style lang="scss">
@use "@/assets/css/chat.scss" as *;
.author {
font-size: 80%;
}
.close {
position: absolute;
bottom: 40px;
}
.slow-fade-enter-active,
.slow-fade-leave-active {
transition: opacity 2.5s;
}
.slow-fade-enter, .slow-fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
.goodbye-wrapper {
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
z-index: 100;
color: black;
background-color: white;
align-items: center;
justify-content: center;
padding: 40px;
}
</style>

View file

@ -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;

View file

@ -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;

View file

@ -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 => {