diff --git a/web/index.html b/web/index.html index 2733bf3c..45c6fac2 100644 --- a/web/index.html +++ b/web/index.html @@ -2,11 +2,11 @@ - + - MeetingIM + 迅工捷使
diff --git a/web/public/app-icon.png b/web/public/app-icon.png new file mode 100644 index 00000000..e3205e42 Binary files /dev/null and b/web/public/app-icon.png differ diff --git a/web/public/favicon.svg b/web/public/favicon.svg deleted file mode 100644 index 378c1cb9..00000000 --- a/web/public/favicon.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - M - diff --git a/web/public/message-notification.mp3 b/web/public/message-notification.mp3 new file mode 100644 index 00000000..727b77bd Binary files /dev/null and b/web/public/message-notification.mp3 differ diff --git a/web/src/features/auth/LoginPage.tsx b/web/src/features/auth/LoginPage.tsx index e3333f8d..239893fd 100644 --- a/web/src/features/auth/LoginPage.tsx +++ b/web/src/features/auth/LoginPage.tsx @@ -61,20 +61,15 @@ export function LoginPage() {
-
M
+ {BASE_APP_NAME}

企业内部沟通,安全、即时、多端同步。

- 单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在桌面端和手机端保持一致。 + 单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在手机端保持一致。

-
- 下载桌面客户端 - iOS / Android - 帮助中心 -
@@ -86,9 +81,7 @@ export function LoginPage() {
-
- M -
+ {tenant.name}
diff --git a/web/src/features/auth/TenantEntryPage.tsx b/web/src/features/auth/TenantEntryPage.tsx index a7fafccb..0dc2a4a5 100644 --- a/web/src/features/auth/TenantEntryPage.tsx +++ b/web/src/features/auth/TenantEntryPage.tsx @@ -6,6 +6,7 @@ import { Link, useNavigate } from 'react-router-dom'; import { Icon } from '@/app/Icon'; import { ImApiError } from '@/lib/api/client'; import { resolveTenant } from '@/lib/api/tenant'; +import { BASE_APP_NAME } from '@/lib/config'; import { resetTinode } from '@/lib/tinode/connection'; import { requestConfirm } from '@/stores/confirmDialog'; import { parseWebsocketEndpoint, useSessionStore } from '@/stores/session'; @@ -77,10 +78,8 @@ export function TenantEntryPage() {
-
- M -
- MeetingIM + + {BASE_APP_NAME}
{error ? (
diff --git a/web/src/features/system/SystemMessagesView.tsx b/web/src/features/system/SystemMessagesView.tsx index e6584d62..2cf1c5f4 100644 --- a/web/src/features/system/SystemMessagesView.tsx +++ b/web/src/features/system/SystemMessagesView.tsx @@ -199,7 +199,7 @@ export function SystemMessagesView() {
系统消息 - 由 MeetingIM 安全团队发送,不可回复 + 由 迅工捷使 安全团队发送,不可回复
{/* `unread` only reflects pages already loaded into this view, but `markAllRead` itself @@ -314,7 +314,7 @@ export function SystemMessagesView() { {/* 08-system-files.dc.html lines 40/108: only the 安全 category gets this footer -- other categories' cards (好友申请/群聊动态/版本更新) have none. */} {item.event_type.startsWith('security.') ? ( -
保持警惕 · MeetingIM 安全团队
+
保持警惕 · 迅工捷使 安全团队
) : null}
); diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts index 5ca1c16a..841846b0 100644 --- a/web/src/lib/config.ts +++ b/web/src/lib/config.ts @@ -2,11 +2,11 @@ // must match the backend (server/*.go) -- they are not stylistic choices, so the numbers/strings // themselves carry over unchanged even though this is a from-scratch rewrite. -/** Version-independent product name (Firebase app name, in-app copy, etc.). */ -export const BASE_APP_NAME = 'MeetingIM'; +/** User-visible product name. */ +export const BASE_APP_NAME = '迅工捷使'; /** Name used in the Tinode client's User-Agent string. */ -export const APP_NAME = `${BASE_APP_NAME}/${__APP_VERSION__}`; +export const APP_NAME = `MeetingIM/${__APP_VERSION__}`; /** * Tinode API key for the WS handshake ({hi} packet) and every REST call's X-MeetingIM-APIKey diff --git a/web/src/lib/notifications/push.ts b/web/src/lib/notifications/push.ts index fef67a73..43a3c2ec 100644 --- a/web/src/lib/notifications/push.ts +++ b/web/src/lib/notifications/push.ts @@ -3,9 +3,10 @@ // Only works while this tab is open (foreground or backgrounded) -- there is no Service Worker/Push // subscription here, so this cannot notify once the browser itself is fully closed. That would need // a backend push-subscription endpoint + VAPID keys, a materially bigger feature than this. -import type { StoredMessage } from 'tinode-sdk'; +import type { Contact, StoredMessage } from 'tinode-sdk'; import { formatMessagePreview } from '@/lib/messages/preview'; +import { playMessageNotificationSound } from '@/lib/notifications/sound'; import { getActiveConversationTopic, getChatListSnapshot, @@ -74,21 +75,21 @@ function checkTopic(topicName: string) { const age = Number.isFinite(ts) ? Date.now() - ts : Infinity; if (age > LIVE_MESSAGE_MAX_AGE_MS) return; } - if (shouldNotify(topicName, latest)) fireNotification(topicName, latest); + notifyIncomingMessage(topicName, latest, getChatListSnapshot().find((c) => c.topic === topicName)); } -function shouldNotify(topicName: string, msg: StoredMessage): boolean { - if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return false; +export function notifyIncomingMessage(topicName: string, msg: StoredMessage, contact?: Contact): void { const myUserId = useSessionStore.getState().myUserId; // Your own message, echoed back (e.g. sent from another tab/device) -- never alert yourself. - if (msg.from && myUserId && msg.from === myUserId) return false; + if (msg.from && myUserId && msg.from === myUserId) return; // Already looking at this exact conversation, in the tab that's actually on screen right now -- // the active-topic check alone isn't enough, since a backgrounded tab still keeps ConversationView // mounted. - if (topicName === getActiveConversationTopic() && document.visibilityState === 'visible') return false; - const contact = getChatListSnapshot().find((c) => c.topic === topicName); - if (contact?.private?.mute_notifications) return false; - return true; + if (topicName === getActiveConversationTopic() && document.visibilityState === 'visible') return; + if (contact?.private?.mute_notifications) return; + playMessageNotificationSound(); + if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return; + fireNotification(topicName, msg, contact); } /** Shared by both real call sites below -- a new message, or being newly added to a group. `tag: @@ -97,7 +98,7 @@ function shouldNotify(topicName: string, msg: StoredMessage): boolean { function showNotification(topicName: string, title: string, body: string) { let notif: Notification; try { - notif = new Notification(title, { body, tag: topicName, icon: '/favicon.svg' }); + notif = new Notification(title, { body, tag: topicName, icon: '/app-icon.png' }); } catch { // Some browsers/contexts throw constructing Notification even with permission granted (e.g. an // OS-level notification setting disabled after the fact) -- never let this crash the message @@ -111,8 +112,7 @@ function showNotification(topicName: string, title: string, body: string) { }; } -function fireNotification(topicName: string, msg: StoredMessage) { - const contact = getChatListSnapshot().find((c) => c.topic === topicName); +function fireNotification(topicName: string, msg: StoredMessage, contact?: Contact) { const title = contact?.public?.fn || topicName; const myUserId = useSessionStore.getState().myUserId; const body = formatMessagePreview(msg, myUserId) || '发来一条新消息'; diff --git a/web/src/lib/notifications/sound.ts b/web/src/lib/notifications/sound.ts new file mode 100644 index 00000000..89ebb9c4 --- /dev/null +++ b/web/src/lib/notifications/sound.ts @@ -0,0 +1,11 @@ +let audio: HTMLAudioElement | null = null; + +export function playMessageNotificationSound(): void { + try { + audio ??= new Audio('/message-notification.mp3'); + audio.currentTime = 0; + void audio.play().catch(() => {}); + } catch { + // Audio can be unavailable or playback can throw; message handling must continue. + } +} diff --git a/web/src/lib/notifications/tabIndicator.ts b/web/src/lib/notifications/tabIndicator.ts index a3fee0f8..98e0bafc 100644 --- a/web/src/lib/notifications/tabIndicator.ts +++ b/web/src/lib/notifications/tabIndicator.ts @@ -43,18 +43,18 @@ document.addEventListener('visibilitychange', () => { }); let faviconImg: HTMLImageElement | null = null; -const FAVICON_HREF = '/favicon.svg'; +const FAVICON_HREF = '/app-icon.png'; /** Draws a small red circle + white count in the favicon's top-right corner onto a canvas, then * points the existing `` at the resulting PNG data URL -- mutating the same link * element's href/type rather than adding a second competing ``, since browsers' - * tie-breaking between multiple icon links is inconsistent. Reverts to the plain SVG favicon at 0. */ + * tie-breaking between multiple icon links is inconsistent. Reverts to the plain PNG favicon at 0. */ function updateFaviconBadge(count: number) { const link = document.querySelector('link[rel="icon"]'); if (!link) return; if (count <= 0) { link.href = FAVICON_HREF; - link.type = 'image/svg+xml'; + link.type = 'image/png'; return; } if (!faviconImg) { diff --git a/web/tests/brand-and-notification-sound.test.mjs b/web/tests/brand-and-notification-sound.test.mjs new file mode 100644 index 00000000..ea73f26d --- /dev/null +++ b/web/tests/brand-and-notification-sound.test.mjs @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFile, stat } from 'node:fs/promises'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { createJiti } from 'jiti'; + +globalThis.__APP_VERSION__ = 'test'; +globalThis.window = { addEventListener() {}, removeEventListener() {}, focus() {} }; +globalThis.document = { visibilityState: 'hidden', addEventListener() {}, title: '' }; +globalThis.localStorage = { getItem() { return null; }, setItem() {}, removeItem() {} }; +window.localStorage = localStorage; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const jiti = createJiti(import.meta.url, { alias: { '@': `${root}src` } }); +const source = (path) => readFile(new URL(`../${path}`, import.meta.url), 'utf8'); +let soundAudio; + +test('display brand changes while the Tinode client identifier stays MeetingIM', async () => { + const { BASE_APP_NAME, APP_NAME } = await jiti.import('../src/lib/config.ts'); + assert.equal(BASE_APP_NAME, '迅工捷使'); + assert.equal(APP_NAME, 'MeetingIM/test'); + assert.match(await source('index.html'), /迅工捷使<\/title>/); + assert.match(await source('src/features/auth/TenantEntryPage.tsx'), />\{BASE_APP_NAME\}<\/strong>/); + const system = await source('src/features/system/SystemMessagesView.tsx'); + assert.equal((system.match(/迅工捷使 安全团队/g) ?? []).length, 2); +}); + +test('login uses the official icon and current mobile copy without unsupported links', async () => { + const login = await source('src/features/auth/LoginPage.tsx'); + const tenant = await source('src/features/auth/TenantEntryPage.tsx'); + assert.equal((login.match(/src="\/app-icon\.png"/g) ?? []).length, 2); + assert.match(tenant, /src="\/app-icon\.png"/); + assert.doesNotMatch(login, />\s*M\s*</); + assert.doesNotMatch(tenant, />\s*M\s*</); + assert.match(login, /单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在手机端保持一致。/); + for (const stale of ['下载桌面客户端', 'iOS / Android', '帮助中心']) assert.doesNotMatch(login, new RegExp(stale)); +}); + +test('official image and supplied sound are copied byte for byte and used for icons', async () => { + const digest = async (path) => createHash('sha256').update(await readFile(`${root}public/${path}`)).digest('hex'); + assert.equal(await digest('app-icon.png'), 'e5d1487e61b70b0df1574b3809ab80072c7e6aecb1231c7dadac2f4cc13be7b6'); + assert.equal(await digest('message-notification.mp3'), 'f0d9e1db55d578504b54b752b113b2c2ca91937594edc19247c0f2a3947a9654'); + const html = await source('index.html'); + const tab = await source('src/lib/notifications/tabIndicator.ts'); + const push = await source('src/lib/notifications/push.ts'); + assert.match(html, /rel="icon" type="image\/png" href="\/app-icon\.png"/); + assert.match(tab, /FAVICON_HREF = '\/app-icon\.png'/); + assert.match(push, /icon: '\/app-icon\.png'/); + await assert.rejects(stat(`${root}public/favicon.svg`), { code: 'ENOENT' }); +}); + +test('sound is lazy, rewinds on repeat, and ignores sync and async playback failures', async () => { + const sound = await jiti.import('../src/lib/notifications/sound.ts'); + assert.equal(typeof sound.playMessageNotificationSound, 'function'); + const played = []; + globalThis.Audio = class { + constructor(src) { this.src = src; this.currentTime = 7; played.push(this); } + play() { return Promise.resolve(); } + }; + assert.equal(played.length, 0); + sound.playMessageNotificationSound(); + sound.playMessageNotificationSound(); + assert.equal(played.length, 1); + soundAudio = played[0]; + assert.equal(played[0].src, '/message-notification.mp3'); + assert.equal(played[0].currentTime, 0); + played[0].play = () => { throw new Error('blocked'); }; + assert.doesNotThrow(() => sound.playMessageNotificationSound()); + played[0].play = () => Promise.reject(new Error('blocked')); + assert.doesNotThrow(() => sound.playMessageNotificationSound()); + await new Promise((resolve) => setImmediate(resolve)); +}); + +test('incoming alerts gate own, visible and muted messages, but sound needs no notification permission', async () => { + const { notifyIncomingMessage } = await jiti.import('../src/lib/notifications/push.ts'); + assert.equal(typeof notifyIncomingMessage, 'function'); + const { useSessionStore } = await jiti.import('../src/stores/session.ts'); + const { setActiveConversationTopic } = await jiti.import('../src/lib/tinode/topics.ts'); + useSessionStore.setState({ myUserId: 'me' }); + let plays = 0; + soundAudio.play = () => { plays++; return Promise.resolve(); }; + let notifications = 0; + globalThis.Notification = class { + static permission = 'default'; + constructor() { notifications++; } + }; + const contact = { topic: 'p2p-test', public: { fn: '朋友' }, private: {} }; + notifyIncomingMessage('p2p-test', { from: 'me' }, contact); + setActiveConversationTopic('p2p-test'); + document.visibilityState = 'visible'; + notifyIncomingMessage('p2p-test', { from: 'peer' }, contact); + setActiveConversationTopic(null); + document.visibilityState = 'hidden'; + notifyIncomingMessage('p2p-test', { from: 'peer' }, { ...contact, private: { mute_notifications: true } }); + assert.equal(plays, 0); + notifyIncomingMessage('p2p-test', { from: 'peer' }, contact); + assert.equal(plays, 1); + assert.equal(notifications, 0); + delete globalThis.Notification; + notifyIncomingMessage('p2p-test', { from: 'peer' }, contact); + assert.equal(plays, 2); + assert.equal(notifications, 0); + globalThis.Notification = class { + static permission = 'granted'; + constructor() { notifications++; } + }; + notifyIncomingMessage('p2p-test', { from: 'peer' }, contact); + assert.equal(plays, 3); + assert.equal(notifications, 1); +});