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() {
);
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 `