feat(matrix-web): add browser notifications for new messages

- Create notification service with permission handling and settings
- Integrate notifications into Matrix store timeline event handler
- Add notification settings panel with toggles for:
  - Enable/disable notifications
  - Sound on/off
  - Message preview visibility
- Show notifications only when page is not focused
- Handle permission states: granted, denied, default

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-29 18:38:43 +01:00
parent 60b7cad508
commit 4492273942
3 changed files with 317 additions and 3 deletions

View file

@ -1,5 +1,6 @@
import { browser } from '$app/environment';
import type { MatrixClient, Room, MatrixEvent, RoomMember as SDKRoomMember } from 'matrix-js-sdk';
import { showMessageNotification, canShowNotifications, isDocumentFocused } from '$lib/notifications';
import type {
SyncState,
MatrixCredentials,
@ -248,6 +249,27 @@ class MatrixStore {
if (room?.roomId === this._currentRoomId) {
this._timeline = [...(room.getLiveTimeline().getEvents() || [])];
}
// Show browser notification for new messages from others
if (
browser &&
event.getType() === 'm.room.message' &&
event.getSender() !== this._client!.getUserId() &&
!isDocumentFocused()
) {
const content = event.getContent();
const body = content?.body || '';
const senderName = this.getSenderName(event);
const roomName = room?.name || 'Unbekannt';
showMessageNotification(senderName, body, roomName, {
onClick: () => {
if (room) {
this.selectRoom(room.roomId);
}
},
});
}
});
// Typing indicators

View file

@ -0,0 +1,171 @@
import { browser } from '$app/environment';
/**
* Browser Notification Service for Matrix Chat
*/
// Notification settings stored in localStorage
const SETTINGS_KEY = 'matrix_notification_settings';
interface NotificationSettings {
enabled: boolean;
sound: boolean;
showPreview: boolean;
}
const defaultSettings: NotificationSettings = {
enabled: true,
sound: true,
showPreview: true,
};
/**
* Get notification settings from localStorage
*/
export function getNotificationSettings(): NotificationSettings {
if (!browser) return defaultSettings;
try {
const stored = localStorage.getItem(SETTINGS_KEY);
if (stored) {
return { ...defaultSettings, ...JSON.parse(stored) };
}
} catch (e) {
console.warn('Failed to parse notification settings:', e);
}
return defaultSettings;
}
/**
* Save notification settings to localStorage
*/
export function saveNotificationSettings(settings: Partial<NotificationSettings>): void {
if (!browser) return;
const current = getNotificationSettings();
const updated = { ...current, ...settings };
localStorage.setItem(SETTINGS_KEY, JSON.stringify(updated));
}
/**
* Check if browser notifications are supported
*/
export function isNotificationSupported(): boolean {
return browser && 'Notification' in window;
}
/**
* Get current notification permission status
*/
export function getNotificationPermission(): NotificationPermission | 'unsupported' {
if (!isNotificationSupported()) return 'unsupported';
return Notification.permission;
}
/**
* Request notification permission
*/
export async function requestNotificationPermission(): Promise<NotificationPermission | 'unsupported'> {
if (!isNotificationSupported()) return 'unsupported';
try {
const permission = await Notification.requestPermission();
return permission;
} catch (e) {
console.error('Failed to request notification permission:', e);
return 'denied';
}
}
/**
* Check if notifications are enabled and permitted
*/
export function canShowNotifications(): boolean {
if (!isNotificationSupported()) return false;
if (Notification.permission !== 'granted') return false;
const settings = getNotificationSettings();
return settings.enabled;
}
/**
* Check if the document is currently focused
*/
export function isDocumentFocused(): boolean {
if (!browser) return true;
return document.hasFocus();
}
/**
* Show a browser notification for a new message
*/
export function showMessageNotification(
senderName: string,
messageBody: string,
roomName: string,
options?: {
onClick?: () => void;
icon?: string;
}
): void {
if (!canShowNotifications()) return;
if (isDocumentFocused()) return;
const settings = getNotificationSettings();
const title = roomName ? `${senderName} in ${roomName}` : senderName;
const body = settings.showPreview ? messageBody : 'Neue Nachricht';
try {
const notification = new Notification(title, {
body: body.slice(0, 200), // Limit body length
icon: options?.icon || '/favicon.png',
tag: 'matrix-message', // Group notifications
silent: !settings.sound,
} as NotificationOptions);
notification.onclick = () => {
window.focus();
notification.close();
options?.onClick?.();
};
// Auto-close after 5 seconds
setTimeout(() => {
notification.close();
}, 5000);
} catch (e) {
console.error('Failed to show notification:', e);
}
}
/**
* Play notification sound
*/
export function playNotificationSound(): void {
if (!browser) return;
const settings = getNotificationSettings();
if (!settings.sound) return;
try {
// Create a simple beep using Web Audio API
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800;
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.3);
} catch (e) {
// Ignore audio errors (common when user hasn't interacted yet)
}
}

View file

@ -14,13 +14,41 @@
Key,
DeviceMobile,
CircleNotch,
BellRinging,
SpeakerHigh,
Eye,
} from '@manacore/shared-icons';
import { VerificationDialog, RecoveryKeyDialog } from '$lib/components/crypto';
import {
getNotificationSettings,
saveNotificationSettings,
getNotificationPermission,
requestNotificationPermission,
isNotificationSupported,
} from '$lib/notifications';
import { browser } from '$app/environment';
let verificationDialogOpen = $state(false);
let recoveryDialogOpen = $state(false);
let recoveryDialogMode = $state<'setup' | 'restore'>('setup');
// Notification settings
let notificationSettings = $state(getNotificationSettings());
let notificationPermission = $state<NotificationPermission | 'unsupported'>(
browser ? getNotificationPermission() : 'default'
);
let notificationsSupported = browser && isNotificationSupported();
async function handleRequestPermission() {
const permission = await requestNotificationPermission();
notificationPermission = permission;
}
function updateNotificationSetting(key: keyof typeof notificationSettings, value: boolean) {
notificationSettings = { ...notificationSettings, [key]: value };
saveNotificationSettings({ [key]: value });
}
// Crypto status derived
let cryptoReady = $derived(matrixStore.cryptoReady);
let verificationStatus = $derived(matrixStore.verificationStatus);
@ -191,14 +219,107 @@
</div>
</section>
<!-- Notifications Section (Placeholder) -->
<!-- Notifications Section -->
<section class="card">
<div class="space-y-2">
<div class="space-y-4">
<h2 class="flex items-center gap-2 text-lg font-semibold">
<Bell class="h-5 w-5" />
Benachrichtigungen
</h2>
<p class="text-sm text-muted-foreground">Benachrichtigungseinstellungen folgen bald...</p>
{#if !notificationsSupported}
<p class="text-sm text-muted-foreground">
Dein Browser unterstützt keine Benachrichtigungen.
</p>
{:else if notificationPermission === 'denied'}
<div class="rounded-lg bg-error/10 p-4 text-sm">
<p class="font-medium text-error">Benachrichtigungen blockiert</p>
<p class="mt-1 text-muted-foreground">
Du hast Benachrichtigungen für diese Seite blockiert. Bitte ändere die Einstellung
in deinem Browser.
</p>
</div>
{:else if notificationPermission === 'default'}
<div class="flex items-center justify-between rounded-lg bg-muted p-4">
<div class="flex items-center gap-3">
<BellRinging class="h-8 w-8 text-primary" />
<div>
<p class="font-medium">Benachrichtigungen aktivieren</p>
<p class="text-sm text-muted-foreground">
Erhalte Benachrichtigungen für neue Nachrichten
</p>
</div>
</div>
<button class="btn-primary text-sm" onclick={handleRequestPermission}>
Erlauben
</button>
</div>
{:else}
<div class="space-y-3">
<!-- Enable/Disable Toggle -->
<label class="flex items-center justify-between rounded-lg bg-muted p-4 cursor-pointer">
<div class="flex items-center gap-3">
<BellRinging class="h-6 w-6" />
<div>
<p class="font-medium">Benachrichtigungen</p>
<p class="text-sm text-muted-foreground">
Desktop-Benachrichtigungen für neue Nachrichten
</p>
</div>
</div>
<input
type="checkbox"
class="toggle toggle-primary"
checked={notificationSettings.enabled}
onchange={() =>
updateNotificationSetting('enabled', !notificationSettings.enabled)}
/>
</label>
<!-- Sound Toggle -->
<label
class="flex items-center justify-between rounded-lg bg-muted p-4 cursor-pointer {!notificationSettings.enabled ? 'opacity-50' : ''}"
>
<div class="flex items-center gap-3">
<SpeakerHigh class="h-6 w-6" />
<div>
<p class="font-medium">Ton</p>
<p class="text-sm text-muted-foreground">Ton bei neuen Nachrichten abspielen</p>
</div>
</div>
<input
type="checkbox"
class="toggle toggle-primary"
checked={notificationSettings.sound}
disabled={!notificationSettings.enabled}
onchange={() => updateNotificationSetting('sound', !notificationSettings.sound)}
/>
</label>
<!-- Preview Toggle -->
<label
class="flex items-center justify-between rounded-lg bg-muted p-4 cursor-pointer {!notificationSettings.enabled ? 'opacity-50' : ''}"
>
<div class="flex items-center gap-3">
<Eye class="h-6 w-6" />
<div>
<p class="font-medium">Vorschau anzeigen</p>
<p class="text-sm text-muted-foreground">
Nachrichteninhalt in Benachrichtigung anzeigen
</p>
</div>
</div>
<input
type="checkbox"
class="toggle toggle-primary"
checked={notificationSettings.showPreview}
disabled={!notificationSettings.enabled}
onchange={() =>
updateNotificationSetting('showPreview', !notificationSettings.showPreview)}
/>
</label>
</div>
{/if}
</div>
</section>