Brand web client and add message notification sound

This commit is contained in:
Your Name
2026-09-30 16:50:14 +08:00
parent 560269c299
commit 1c17165c86
12 changed files with 151 additions and 40 deletions
+2 -2
View File
@@ -2,11 +2,11 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/app-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:400,500,700" />
<title>MeetingIM</title>
<title>迅工捷使</title>
</head>
<body>
<div id="root"></div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 623 KiB

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#1677ff"/>
<text x="16" y="23" text-anchor="middle" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-weight="700" font-size="19" fill="#fff">M</text>
</svg>

Before

Width:  |  Height:  |  Size: 289 B

Binary file not shown.
+3 -10
View File
@@ -61,20 +61,15 @@ export function LoginPage() {
<div className="relative hidden shrink-0 flex-col justify-center overflow-hidden bg-gradient-to-br from-[#1677ff] to-[#0f56c4] px-16 text-white min-[900px]:flex min-[900px]:w-[56%]">
<div className="pointer-events-none absolute inset-x-0 top-0 h-96 bg-[radial-gradient(circle_at_20%_-10%,rgba(255,255,255,0.25),transparent_60%)]" />
<div className="absolute left-16 top-14 flex items-center gap-2.5">
<div className="grid h-10 w-10 place-items-center rounded-[10px] bg-white text-[22px] font-bold text-accent">M</div>
<img src="/app-icon.png" alt="" className="h-10 w-10 rounded-[10px]" />
<strong className="text-xl font-semibold">{BASE_APP_NAME}</strong>
</div>
<h2 className="m-0 max-w-md text-[40px] leading-tight font-semibold text-balance">
企业内部沟通,安全、即时、多端同步。
</h2>
<p className="mt-4 max-w-md text-base text-white/80">
单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在桌面端和手机端保持一致。
单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在手机端保持一致。
</p>
<div className="absolute bottom-14 left-16 flex gap-7 text-[13px] text-white/75">
<span>下载桌面客户端</span>
<span>iOS / Android</span>
<span>帮助中心</span>
</div>
</div>
<div className="flex flex-1 flex-col bg-white px-6 py-8">
@@ -86,9 +81,7 @@ export function LoginPage() {
<div className="flex flex-1 flex-col items-center justify-center">
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-4">
<div className="mb-1 flex flex-col items-center gap-3 min-[900px]:hidden">
<div className="grid h-[72px] w-[72px] place-items-center rounded-2xl bg-accent text-[36px] font-bold text-white shadow-[0_10px_26px_rgba(35,74,125,0.18)]">
M
</div>
<img src="/app-icon.png" alt="" className="h-[72px] w-[72px] rounded-2xl shadow-[0_10px_26px_rgba(35,74,125,0.18)]" />
<strong className="text-[26px] font-bold text-text-title">{tenant.name}</strong>
</div>
<div className="hidden flex-col gap-1 min-[900px]:flex">
+3 -4
View File
@@ -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() {
</Link>
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-5">
<div className="mb-2 flex flex-col items-center gap-3">
<div className="grid h-[72px] w-[72px] place-items-center rounded-2xl bg-accent text-[36px] font-bold text-white shadow-[0_10px_26px_rgba(35,74,125,0.18)]">
M
</div>
<strong className="text-[26px] font-bold text-text-title">MeetingIM</strong>
<img src="/app-icon.png" alt="" className="h-[72px] w-[72px] rounded-2xl shadow-[0_10px_26px_rgba(35,74,125,0.18)]" />
<strong className="text-[26px] font-bold text-text-title">{BASE_APP_NAME}</strong>
</div>
{error ? (
<div className="flex items-center gap-1.5 rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">
@@ -199,7 +199,7 @@ export function SystemMessagesView() {
</span>
<div className="flex flex-col">
<strong className="text-[15px] font-semibold text-text-heading">系统消息</strong>
<span className="text-xs text-text-tertiary-3">由 MeetingIM 安全团队发送,不可回复</span>
<span className="text-xs text-text-tertiary-3">由 迅工捷使 安全团队发送,不可回复</span>
</div>
</div>
{/* `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.') ? (
<footer className="mt-3 border-t border-border-row pt-2.5 text-xs text-text-tertiary-3">保持警惕 · MeetingIM 安全团队</footer>
<footer className="mt-3 border-t border-border-row pt-2.5 text-xs text-text-tertiary-3">保持警惕 · 迅工捷使 安全团队</footer>
) : null}
</div>
);
+3 -3
View File
@@ -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
+12 -12
View File
@@ -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) || '发来一条新消息';
+11
View File
@@ -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.
}
}
+3 -3
View File
@@ -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 `<link rel="icon">` at the resulting PNG data URL -- mutating the same link
* element's href/type rather than adding a second competing `<link rel="icon">`, 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<HTMLLinkElement>('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) {
@@ -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>迅工捷使<\/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);
});