Files
im_webapp/service-worker.js
T
Your NameandClaude Sonnet 5 4e8f45d7d8 路由从 hash 换成 History API(pushState/replaceState/popstate),不再依赖 location.hash
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>
2026-09-07 21:00:39 +08:00

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];
});