HashNavigation 改名 Navigation(src/lib/navigation.js),接口不变,内部改用真实路径: parseUrlHash/composeUrlHash 改名 parseUrl/composeUrl;navigateTo/navigateReplace 改调 pushState/replaceState 后手动派发 popstate(pushState/replaceState 不会像赋值 location.hash 那样自动触发事件,这里复用 navigateReplace 早就在用的同一个手动 dispatch 手法);goBack() 不用改,history.back() 本来就是真实浏览器历史机制。新增 Navigation.currentUrl() 替代调用方 原本直接读的 window.location.hash(这个类从来没包装过这个读取)。 一处需要专门处理的边界情况:当 path[0] 是空字符串("不选中侧栏视图、只开一个会话"这个仓库里 的合法状态)时,拼出的真实路径开头会变成双斜杠,被浏览器当协议相对地址解析、试图跳到一个叫 会话id的host,pushState 直接抛 SecurityError。composeUrl/parseUrl 内部用占位 token "_" 处理 这种情况,对外的调用方(19个文件)完全无感。 tinode-web.jsx 的 handleHashRoute 改名 handleRouteChange,监听从 hashchange 换成 popstate; service-worker.js 的推送通知点击跳转手动改写成同样的路径格式(这个文件在打包之外单独执行, 没法 import Navigation)。index.html/index-dev.html 加了 <base href="/">——所有静态资源引用 都是相对路径,hash 路由下页面路径永远是 /,换真实路径后深层地址刷新会按当前目录解析出错误 资源地址导致白屏,这一行是标准修法。nginx.conf 的 try_files 兜底规则本来就适用两种路由,只 更新了注释。 实机验证:地址栏不再有 #;点击会话/设置等各类视图正常跳转;浏览器前进后退和应用内"返回" 按钮都正常;收藏页内部的类型 tab 切换(navigateReplace)确认不产生新的历史记录;在会话页/ 个人中心页刷新、直接用完整深层路径打开新标签页都能正确恢复到位,资源正常加载不白屏。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
211 lines
6.5 KiB
JavaScript
211 lines
6.5 KiB
JavaScript
// Must be located at the root.
|
|
importScripts('https://cdn.jsdelivr.net/npm/firebase@9.14.0/firebase-app-compat.js');
|
|
importScripts('https://cdn.jsdelivr.net/npm/firebase@9.14.0/firebase-messaging-compat.js');
|
|
importScripts('firebase-init.js');
|
|
importScripts('version.js');
|
|
|
|
// Channel to notify the webapp. There is no BroadcastChannel in Safari.
|
|
const webAppChannel = (typeof BroadcastChannel == 'function') ?
|
|
new BroadcastChannel('tinode-sw') : null;
|
|
|
|
// Basic internationalization.
|
|
const i18n = {
|
|
'ar': {
|
|
'new_message': "رسالة جديدة",
|
|
'new_chat': "محادثة جديدة",
|
|
},
|
|
'de': {
|
|
'new_message': "Neue Nachricht",
|
|
'new_chat': "Neuer Chat",
|
|
},
|
|
'en': {
|
|
'new_message': "New message",
|
|
'new_chat': "New chat",
|
|
},
|
|
'fr': {
|
|
'new_message': "Nouveau message",
|
|
'new_chat': "Nouvelle conversation",
|
|
},
|
|
'es': {
|
|
'new_message': "Nuevo mensaje",
|
|
'new_chat': "Nueva conversación",
|
|
},
|
|
'it': {
|
|
'new_message': "Nuovo Messaggio",
|
|
'new_chat': "Nuova Chat",
|
|
},
|
|
'ko': {
|
|
'new_message': "새로운 메시지",
|
|
'new_chat': "새로운 채팅",
|
|
},
|
|
'pt': {
|
|
'new_message': "Nova Mensagem",
|
|
'new_chat': "Nova Conversa",
|
|
},
|
|
'ro': {
|
|
'new_message': "Mesaj nou",
|
|
'new_chat': "Chat nou",
|
|
},
|
|
'ru': {
|
|
'new_message': "Новое сообщение",
|
|
'new_chat': "Новый чат",
|
|
},
|
|
'th': {
|
|
'new_message': "ข้อความใหม่",
|
|
'new_chat': "แชทใหม่",
|
|
},
|
|
'uk': {
|
|
'new_message': "Нове повідомлення",
|
|
'new_chat': "Новий чат",
|
|
},
|
|
'vi': {
|
|
'new_message': "Tin nhắn mới",
|
|
'new_chat': "Cuộc trò chuyện mới",
|
|
},
|
|
'zh': {
|
|
'new_message': "新讯息",
|
|
'new_chat': "新聊天",
|
|
},
|
|
'zh-TW': {
|
|
'new_message': "新訊息",
|
|
'new_chat': "新聊天",
|
|
}
|
|
};
|
|
|
|
self.i18nMessage = function (id) {
|
|
if (!id) {
|
|
return null;
|
|
}
|
|
// Choose translations: given something like 'de-CH', try 'de-CH' then 'de' then 'en'.
|
|
const lang = i18n[self.locale] || i18n[self.baseLocale] || i18n['en'];
|
|
// Try finding string by id in the specified language, if missing try English, otherwise use the id itself
|
|
// as the last resort.
|
|
return lang[id] || i18n['en'][id] || id;
|
|
}
|
|
|
|
firebase.initializeApp(FIREBASE_INIT);
|
|
const fbMessaging = firebase.messaging();
|
|
|
|
// This method shows the push notifications while the window is in background.
|
|
fbMessaging.onBackgroundMessage(payload => {
|
|
// Notify webapp that a message was received.
|
|
if (webAppChannel) {
|
|
webAppChannel.postMessage(payload.data);
|
|
}
|
|
|
|
if (payload.data.silent == 'true') {
|
|
// No need to show anything.
|
|
return;
|
|
}
|
|
|
|
const titles = { 'msg': 'new_message', 'sub': 'new_chat' };
|
|
const pushType = payload.data.what || 'msg';
|
|
const title = payload.data.title || self.i18nMessage(titles[pushType]);
|
|
if (title) {
|
|
const options = {
|
|
body: payload.data.content || '', // TODO: content for 'sub' should be topic's or user's title.
|
|
icon: '/img/logo96.png', // TODO: use topic's or user's avatar (would have to fetch for 'sub', read from db for 'msg').
|
|
badge: '/img/badge96.png',
|
|
tag: payload.data.topic || undefined,
|
|
data: {
|
|
topic: payload.data.topic
|
|
}
|
|
};
|
|
return self.registration.showNotification(title, options);
|
|
}
|
|
});
|
|
|
|
// Update service worker immediately for both the current client
|
|
// and all other active clients.
|
|
self.addEventListener('install', _ => {
|
|
self.skipWaiting();
|
|
});
|
|
|
|
// This code handles a click on notification: takes
|
|
// the user to the browser tab with the chat or opens a new tab.
|
|
self.addEventListener('notificationclick', event => {
|
|
const data = event.notification.data;
|
|
event.notification.close();
|
|
if (!data) {
|
|
console.info("Missing 'data' in notification", event.notification);
|
|
return;
|
|
}
|
|
|
|
// '_' mirrors the placeholder Navigation.composeUrl (src/lib/navigation.js) substitutes for an
|
|
// empty path[0] ("no sidepanel view, just open this topic") -- a real empty first segment would
|
|
// make the path start with '//', which pushState/URL resolve as a protocol-relative reference
|
|
// (host becomes "data.topic") rather than a same-origin path. This file runs outside the webpack
|
|
// bundle (importScripts, not an ES module) so it can't import the constant; kept in sync by hand.
|
|
const urlPath = '/_/' + data.topic;
|
|
|
|
event.waitUntil(self.clients.matchAll({
|
|
type: 'window',
|
|
includeUncontrolled: true
|
|
})
|
|
.then(windowClients => {
|
|
let anyClient = null;
|
|
for (let i = 0; i < windowClients.length; i++) {
|
|
const url = new URL(windowClients[i].url);
|
|
if (url.pathname.includes(data.topic)) {
|
|
// Found the Tinode tab with the right topic open.
|
|
return windowClients[i].focus();
|
|
} else {
|
|
// This will be the least recently used tab.
|
|
anyClient = windowClients[i];
|
|
}
|
|
}
|
|
|
|
// Found tab with Tinode on a different topic,
|
|
// navigate to the right topic.
|
|
if (anyClient) {
|
|
const url = new URL(anyClient.url);
|
|
url.pathname = urlPath;
|
|
return anyClient.focus().then(thisClient => {
|
|
return thisClient.navigate(url);
|
|
});
|
|
}
|
|
|
|
// Did not find a Tinode browser tab. Open one.
|
|
const url = new URL(self.location.origin);
|
|
url.pathname = urlPath;
|
|
return clients.openWindow(url);
|
|
}));
|
|
});
|
|
|
|
// This is needed for 'Add to Home Screen'.
|
|
self.addEventListener('fetch', event => {
|
|
if (event.request.method != 'GET') {
|
|
return;
|
|
}
|
|
|
|
event.respondWith((async _ => {
|
|
// Try to find the response in the cache.
|
|
const cache = await caches.open(PACKAGE_VERSION);
|
|
|
|
const reqUrl = new URL(event.request.url);
|
|
// Using ignoreSearch=true to read cached images and docs despite different auth signatures.
|
|
const cachedResponse = await cache.match(event.request, { ignoreSearch: (self.location.origin == reqUrl.origin) });
|
|
if (cachedResponse) {
|
|
return cachedResponse;
|
|
}
|
|
// Not found in cache.
|
|
const response = await fetch(event.request);
|
|
if (!response || response.status != 200 || response.type != 'basic') {
|
|
return response;
|
|
}
|
|
if (reqUrl.protocol == 'http:' || reqUrl.protocol == 'https:') {
|
|
await cache.put(event.request, response.clone());
|
|
}
|
|
return response;
|
|
})());
|
|
});
|
|
|
|
// This code gets the human language from the webapp.
|
|
self.addEventListener('message', event => {
|
|
const data = JSON.parse(event.data);
|
|
|
|
// The locale is used for selecting strings in an appropriate language.
|
|
self.locale = data.locale || '';
|
|
self.baseLocale = self.locale.toLowerCase().split(/[-_]/)[0];
|
|
});
|