Brand web client and add message notification sound
This commit is contained in:
+2
-2
@@ -2,11 +2,11 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<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/icon?family=Material+Icons" />
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:400,500,700" />
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:400,500,700" />
|
||||||
<title>MeetingIM</title>
|
<title>迅工捷使</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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="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="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="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>
|
<strong className="text-xl font-semibold">{BASE_APP_NAME}</strong>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="m-0 max-w-md text-[40px] leading-tight font-semibold text-balance">
|
<h2 className="m-0 max-w-md text-[40px] leading-tight font-semibold text-balance">
|
||||||
企业内部沟通,安全、即时、多端同步。
|
企业内部沟通,安全、即时、多端同步。
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-4 max-w-md text-base text-white/80">
|
<p className="mt-4 max-w-md text-base text-white/80">
|
||||||
单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在桌面端和手机端保持一致。
|
单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在手机端保持一致。
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col bg-white px-6 py-8">
|
<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">
|
<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">
|
<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="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)]">
|
<img src="/app-icon.png" alt="" className="h-[72px] w-[72px] rounded-2xl shadow-[0_10px_26px_rgba(35,74,125,0.18)]" />
|
||||||
M
|
|
||||||
</div>
|
|
||||||
<strong className="text-[26px] font-bold text-text-title">{tenant.name}</strong>
|
<strong className="text-[26px] font-bold text-text-title">{tenant.name}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden flex-col gap-1 min-[900px]:flex">
|
<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 { Icon } from '@/app/Icon';
|
||||||
import { ImApiError } from '@/lib/api/client';
|
import { ImApiError } from '@/lib/api/client';
|
||||||
import { resolveTenant } from '@/lib/api/tenant';
|
import { resolveTenant } from '@/lib/api/tenant';
|
||||||
|
import { BASE_APP_NAME } from '@/lib/config';
|
||||||
import { resetTinode } from '@/lib/tinode/connection';
|
import { resetTinode } from '@/lib/tinode/connection';
|
||||||
import { requestConfirm } from '@/stores/confirmDialog';
|
import { requestConfirm } from '@/stores/confirmDialog';
|
||||||
import { parseWebsocketEndpoint, useSessionStore } from '@/stores/session';
|
import { parseWebsocketEndpoint, useSessionStore } from '@/stores/session';
|
||||||
@@ -77,10 +78,8 @@ export function TenantEntryPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-5">
|
<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="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)]">
|
<img src="/app-icon.png" alt="" className="h-[72px] w-[72px] rounded-2xl shadow-[0_10px_26px_rgba(35,74,125,0.18)]" />
|
||||||
M
|
<strong className="text-[26px] font-bold text-text-title">{BASE_APP_NAME}</strong>
|
||||||
</div>
|
|
||||||
<strong className="text-[26px] font-bold text-text-title">MeetingIM</strong>
|
|
||||||
</div>
|
</div>
|
||||||
{error ? (
|
{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">
|
<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>
|
</span>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<strong className="text-[15px] font-semibold text-text-heading">系统消息</strong>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{/* `unread` only reflects pages already loaded into this view, but `markAllRead` itself
|
{/* `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 --
|
{/* 08-system-files.dc.html lines 40/108: only the 安全 category gets this footer --
|
||||||
other categories' cards (好友申请/群聊动态/版本更新) have none. */}
|
other categories' cards (好友申请/群聊动态/版本更新) have none. */}
|
||||||
{item.event_type.startsWith('security.') ? (
|
{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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
// must match the backend (server/*.go) -- they are not stylistic choices, so the numbers/strings
|
// 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.
|
// themselves carry over unchanged even though this is a from-scratch rewrite.
|
||||||
|
|
||||||
/** Version-independent product name (Firebase app name, in-app copy, etc.). */
|
/** User-visible product name. */
|
||||||
export const BASE_APP_NAME = 'MeetingIM';
|
export const BASE_APP_NAME = '迅工捷使';
|
||||||
|
|
||||||
/** Name used in the Tinode client's User-Agent string. */
|
/** 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
|
* 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
|
// 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
|
// 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.
|
// 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 { formatMessagePreview } from '@/lib/messages/preview';
|
||||||
|
import { playMessageNotificationSound } from '@/lib/notifications/sound';
|
||||||
import {
|
import {
|
||||||
getActiveConversationTopic,
|
getActiveConversationTopic,
|
||||||
getChatListSnapshot,
|
getChatListSnapshot,
|
||||||
@@ -74,21 +75,21 @@ function checkTopic(topicName: string) {
|
|||||||
const age = Number.isFinite(ts) ? Date.now() - ts : Infinity;
|
const age = Number.isFinite(ts) ? Date.now() - ts : Infinity;
|
||||||
if (age > LIVE_MESSAGE_MAX_AGE_MS) return;
|
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 {
|
export function notifyIncomingMessage(topicName: string, msg: StoredMessage, contact?: Contact): void {
|
||||||
if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return false;
|
|
||||||
const myUserId = useSessionStore.getState().myUserId;
|
const myUserId = useSessionStore.getState().myUserId;
|
||||||
// Your own message, echoed back (e.g. sent from another tab/device) -- never alert yourself.
|
// 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 --
|
// 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
|
// the active-topic check alone isn't enough, since a backgrounded tab still keeps ConversationView
|
||||||
// mounted.
|
// mounted.
|
||||||
if (topicName === getActiveConversationTopic() && document.visibilityState === 'visible') return false;
|
if (topicName === getActiveConversationTopic() && document.visibilityState === 'visible') return;
|
||||||
const contact = getChatListSnapshot().find((c) => c.topic === topicName);
|
if (contact?.private?.mute_notifications) return;
|
||||||
if (contact?.private?.mute_notifications) return false;
|
playMessageNotificationSound();
|
||||||
return true;
|
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:
|
/** 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) {
|
function showNotification(topicName: string, title: string, body: string) {
|
||||||
let notif: Notification;
|
let notif: Notification;
|
||||||
try {
|
try {
|
||||||
notif = new Notification(title, { body, tag: topicName, icon: '/favicon.svg' });
|
notif = new Notification(title, { body, tag: topicName, icon: '/app-icon.png' });
|
||||||
} catch {
|
} catch {
|
||||||
// Some browsers/contexts throw constructing Notification even with permission granted (e.g. an
|
// 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
|
// 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) {
|
function fireNotification(topicName: string, msg: StoredMessage, contact?: Contact) {
|
||||||
const contact = getChatListSnapshot().find((c) => c.topic === topicName);
|
|
||||||
const title = contact?.public?.fn || topicName;
|
const title = contact?.public?.fn || topicName;
|
||||||
const myUserId = useSessionStore.getState().myUserId;
|
const myUserId = useSessionStore.getState().myUserId;
|
||||||
const body = formatMessagePreview(msg, 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;
|
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
|
/** 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
|
* 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'
|
* 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) {
|
function updateFaviconBadge(count: number) {
|
||||||
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||||
if (!link) return;
|
if (!link) return;
|
||||||
if (count <= 0) {
|
if (count <= 0) {
|
||||||
link.href = FAVICON_HREF;
|
link.href = FAVICON_HREF;
|
||||||
link.type = 'image/svg+xml';
|
link.type = 'image/png';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!faviconImg) {
|
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