diff --git a/packages/ui-default/build/config/webpack.ts b/packages/ui-default/build/config/webpack.ts index 6edf19584..5078069da 100644 --- a/packages/ui-default/build/config/webpack.ts +++ b/packages/ui-default/build/config/webpack.ts @@ -146,10 +146,21 @@ export default function (env: { production?: boolean, measure?: boolean } = {}) }, { test: /\.[mc]?[jt]sx?$/, - exclude: /@types\//, + exclude: [/@types\//, /components\/message\//], type: 'javascript/auto', use: [esbuildLoader()], }, + { + test: /\.[mc]?[jt]sx?$/, + include: /components\/message\//, + type: 'javascript/auto', + use: [{ + loader: 'ts-loader', + options: { + transpileOnly: true, + }, + }], + }, { test: /\.styl$/, use: [extractCssLoader(), cssLoader(), postcssLoader(), stylusLoader()], diff --git a/packages/ui-default/components/message/index.page.ts b/packages/ui-default/components/message/index.page.ts index 146f65aaa..c18e57d0f 100644 --- a/packages/ui-default/components/message/index.page.ts +++ b/packages/ui-default/components/message/index.page.ts @@ -9,9 +9,10 @@ import tpl from 'vj/utils/tpl'; const onmessage = (msg) => { console.log('Received message', msg); + if (document.hidden) return false; if (msg.mdoc.flag & FLAG_ALERT) { // Is alert - return new InfoDialog({ + new InfoDialog({ cancelByClickingBack: false, $body: tpl`
@@ -19,9 +20,10 @@ const onmessage = (msg) => {

${i18n(msg.mdoc.content)}

`, }).open(); + return true; } // Is message - return new VjNotification({ + new VjNotification({ ...(msg.udoc._id === 1 && msg.mdoc.flag & 4) ? { message: i18n('You received a system message, click here to view.') } : { @@ -32,6 +34,26 @@ const onmessage = (msg) => { duration: 15000, action: () => window.open(`/home/messages?uid=${msg.udoc._id}`, '_blank'), }).show(); + return true; +}; + +const url = new URL('/home/messages-conn', window.location.href); +// TODO handle a better way for cookie +url.searchParams.append('sid', document.cookie); +const endpoint = url.toString().replace('http', 'ws'); + +const initWorkerMode = () => { + console.log('Messages: using SharedWorker'); + const worker = new SharedWorker(new URL('./worker', import.meta.url), { name: 'Hydro Messages Worker' }); + worker.port.start(); + worker.port.postMessage({ type: 'conn', path: endpoint, cookie: document.cookie }); + worker.port.onmessage = async (message) => { + if (process.env.NODE_ENV !== 'production') console.log('onmessage: ', message); + const { payload, type } = message.data; + if (type === 'message') { + if (onmessage(payload)) worker.port.postMessage({ type: 'ack', id: payload.mdoc._id }); + } + }; }; const messagePage = new AutoloadPage('messagePage', (pagename) => { @@ -44,11 +66,16 @@ const messagePage = new AutoloadPage('messagePage', (pagename) => { action: () => window.open('/home/messages', '_blank'), }).show(); } + if (window.SharedWorker) { + initWorkerMode(); + return; + } if (!window.BroadcastChannel) { console.error('BoardcastChannel not supported'); return; } + console.log('Messages: using BroadcastChannel'); let isMaster = false; const selfId = nanoid(); const channel = new BroadcastChannel('hydro-messages'); @@ -70,45 +97,19 @@ const messagePage = new AutoloadPage('messagePage', (pagename) => { isMaster = true; localStorage.setItem('page.master', selfId); const masterChannel = new BroadcastChannel('hydro-messages'); - const url = new URL('/home/messages-conn', window.location.href.replace('http', 'ws')); - // TODO handle a better way for cookie - url.searchParams.append('sid', document.cookie); const sock = new ReconnectingWebsocket(url.toString()); - const pending = {}; sock.onopen = () => console.log('Connected'); sock.onerror = console.error; sock.onclose = (...args) => console.log('Closed', ...args); sock.onmessage = async (message) => { if (process.env.NODE_ENV !== 'production') console.log('onmessage: ', message); const payload = JSON.parse(message.data); - const id = nanoid(); - masterChannel.postMessage({ type: 'message', id, payload }); - const success = await new Promise((resolve) => { - pending[id] = resolve; - setTimeout(() => { - delete pending[id]; - resolve(false); - }, 1000); - }); - if (!success && window.Notification?.permission === 'granted') { - const notification = new window.Notification( - payload.udoc.uname || 'Hydro Notification', - { - icon: payload.udoc.avatarUrl || '/android-chrome-192x192.png', - body: payload.mdoc.content, - }, - ); - notification.onclick = () => window.open('/home/messages'); - } - }; - masterChannel.onmessage = (msg) => { - if (msg.data.type === 'message-push') pending[msg.data.id]?.(true); + masterChannel.postMessage({ type: 'message', payload }); }; } channel.onmessage = (msg) => { if (msg.data.type === 'message' && !document.hidden) { - channel.postMessage({ type: 'message-push', id: msg.data.id }); onmessage(msg.data.payload); } if (msg.data.type === 'master') { diff --git a/packages/ui-default/components/message/worker.ts b/packages/ui-default/components/message/worker.ts new file mode 100644 index 000000000..38cae6cc4 --- /dev/null +++ b/packages/ui-default/components/message/worker.ts @@ -0,0 +1,80 @@ +/* eslint-disable no-restricted-globals */ +/// +import ReconnectingWebsocket from 'reconnecting-websocket'; + +console.log('SharedWorker init'); + +let conn: ReconnectingWebsocket; +let lcookie: string; +const ports: MessagePort[] = []; +interface RequestInitSharedConnPayload { + type: 'conn'; + cookie: string; + path: string; +} +interface RequestAckPayload { + type: 'ack'; + id: string; +} +type RequestPayload = RequestInitSharedConnPayload | RequestAckPayload; +const ack = {}; + +function broadcastMsg(message: any) { + for (const p of ports) p.postMessage(message); +} +function initConn(path: string, port: MessagePort, cookie: any) { + if (cookie !== lcookie) conn?.close(); + else if (conn && conn.readyState === conn.OPEN) return; + lcookie = cookie; + console.log('Init connection for', path); + conn = new ReconnectingWebsocket(path); + ports.push(port); + conn.onopen = () => conn.send(cookie); + conn.onerror = () => broadcastMsg({ type: 'error' }); + conn.onclose = (ev) => broadcastMsg({ type: 'close', error: ev.reason }); + conn.onmessage = (message) => { + if (process.env.NODE_ENV !== 'production') console.log('SharedWorker.port.onmessage: ', message); + const payload = JSON.parse(message.data); + if (payload.event === 'auth') { + if (['PermissionError', 'PrivilegeError'].includes(payload.error)) { + broadcastMsg({ type: 'close', error: payload.error }); + conn.close(); + } else { + console.log('Connected to', path); + broadcastMsg({ type: 'open' }); + } + } else { + broadcastMsg({ type: 'message', payload }); + let acked = false; + ack[payload.mdoc.id] = () => { acked = true; }; + setTimeout(() => { + delete ack[payload.mdoc.id]; + if (acked) return; + if (Notification?.permission !== 'granted') { + console.log('Notification permission denied'); + return; + } + const notification = new Notification( + payload.udoc.uname || 'Hydro Notification', + { + icon: payload.udoc.avatarUrl || '/android-chrome-192x192.png', + body: payload.mdoc.content, + }, + ); + notification.onclick = () => window.open('/home/messages'); + }, 5000); + } + }; +} + +// eslint-disable-next-line no-undef +onconnect = function (e) { + const port = e.ports[0]; + + port.addEventListener('message', (msg: { data: RequestPayload }) => { + if (msg.data.type === 'conn') initConn(msg.data.path, port, msg.data.cookie); + if (msg.data.type === 'ack') ack[msg.data.id](); + }); + + port.start(); +}; diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index c1d715c05..c5a04f4e4 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.39.19", + "version": "4.39.20", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", @@ -31,6 +31,7 @@ "@types/qrcode": "^1.5.0", "@types/react-dom": "^18.0.6", "@types/redux-logger": "^3.0.9", + "@types/sharedworker": "^0.0.80", "@vscode/codicons": "^0.0.32", "autoprefixer": "^10.4.8", "browser-update": "^3.3.40", @@ -104,6 +105,7 @@ "through2": "^4.0.2", "timeago-react": "^3.0.5", "timeago.js": "^4.0.2", + "ts-loader": "^9.3.1", "vditor": "^3.8.17", "vinyl-buffer": "^1.0.1", "web-streams-polyfill": "^3.2.1",