Brand web client and add message notification sound
This commit is contained in:
+2
-2
@@ -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 |
@@ -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.
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) || '发来一条新消息';
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user