@@ -40,6 +40,19 @@ import { mobileOrTablet } from '$utils/user-agent';
4040import { getInboxInvitesPath } from '../pathUtils' ;
4141import { BackgroundNotifications } from './BackgroundNotifications' ;
4242
43+ function clearMediaSessionQuickly ( ) : void {
44+ if ( ! ( 'mediaSession' in navigator ) ) return ;
45+ // iOS registers the lock screen media player as a side-effect of
46+ // HTMLAudioElement.play(). We delay slightly so iOS has finished updating
47+ // the media session before we clear it — clearing too early is a no-op.
48+ // We only clear if no real in-app media (video/audio in a room) has since
49+ // registered meaningful metadata; if it has, leave it alone.
50+ setTimeout ( ( ) => {
51+ if ( navigator . mediaSession . metadata !== null ) return ;
52+ navigator . mediaSession . playbackState = 'none' ;
53+ } , 500 ) ;
54+ }
55+
4356function SystemEmojiFeature ( ) {
4457 const [ twitterEmoji ] = useSetting ( settingsAtom , 'twitterEmoji' ) ;
4558
@@ -157,6 +170,7 @@ function InviteNotifications() {
157170 const playSound = useCallback ( ( ) => {
158171 const audioElement = audioRef . current ;
159172 audioElement ?. play ( ) ;
173+ clearMediaSessionQuickly ( ) ;
160174 } , [ ] ) ;
161175
162176 useEffect ( ( ) => {
@@ -229,6 +243,7 @@ function MessageNotifications() {
229243 const playSound = useCallback ( ( ) => {
230244 const audioElement = audioRef . current ;
231245 audioElement ?. play ( ) ;
246+ clearMediaSessionQuickly ( ) ;
232247 } , [ ] ) ;
233248
234249 useEffect ( ( ) => {
@@ -291,21 +306,52 @@ function MessageNotifications() {
291306 // If neither a loud nor a highlight rule matches, and it's not a DM, nothing to show.
292307 if ( ! isHighlightByRule && ! loudByRule && ! isDM ) return ;
293308
294- // Page hidden: SW (push) handles the OS notification. Nothing to do in-app.
295- if ( document . visibilityState !== 'visible' ) return ;
296-
297309 // Record as notified to prevent duplicate banners (e.g. re-emitted decrypted events).
298310 notifiedEventsRef . current . add ( eventId ) ;
299311 if ( notifiedEventsRef . current . size > 200 ) {
300312 const first = notifiedEventsRef . current . values ( ) . next ( ) . value ;
301313 if ( first ) notifiedEventsRef . current . delete ( first ) ;
302314 }
303315
304- // Page is visible — show the themed in-app notification banner for any
305- // highlighted message (mention / keyword) or loud push rule.
306- // okay fast patch because that showNotifications setting atom is not getting set correctly or something
307- if ( mobileOrTablet ( ) && showNotifications && ( isHighlightByRule || loudByRule || isDM ) ) {
316+ // On desktop: fire an OS notification so the user is alerted even when the
317+ // browser window is minimised or the tab is not active.
318+ if ( ! mobileOrTablet ( ) && showSystemNotifications && notificationPermission ( 'granted' ) ) {
308319 const isEncryptedRoom = ! ! getStateEvent ( room , StateEvent . RoomEncryption ) ;
320+ const avatarMxc =
321+ room . getAvatarFallbackMember ( ) ?. getMxcAvatarUrl ( ) ?? room . getMxcAvatarUrl ( ) ;
322+ const osPayload = buildRoomMessageNotification ( {
323+ roomName : room . name ?? 'Unknown' ,
324+ roomAvatar : avatarMxc
325+ ? ( mxcUrlToHttp ( mx , avatarMxc , useAuthentication , 96 , 96 , 'crop' ) ?? undefined )
326+ : undefined ,
327+ username :
328+ getMemberDisplayName ( room , sender , nicknamesRef . current ) ??
329+ getMxIdLocalPart ( sender ) ??
330+ sender ,
331+ previewText : resolveNotificationPreviewText ( {
332+ content : mEvent . getContent ( ) ,
333+ eventType : mEvent . getType ( ) ,
334+ isEncryptedRoom,
335+ showMessageContent,
336+ showEncryptedMessageContent,
337+ } ) ,
338+ silent : ! notificationSound || ! loudByRule ,
339+ eventId,
340+ } ) ;
341+ const noti = new window . Notification ( osPayload . title , osPayload . options ) ;
342+ const { roomId } = room ;
343+ noti . onclick = ( ) => {
344+ window . focus ( ) ;
345+ setPending ( { roomId, eventId, targetSessionId : mx . getUserId ( ) ?? undefined } ) ;
346+ noti . close ( ) ;
347+ } ;
348+ }
349+
350+ // Everything below requires the page to be visible (in-app UI + audio).
351+ if ( document . visibilityState !== 'visible' ) return ;
352+
353+ // Page is visible — show the themed in-app notification banner.
354+ if ( showNotifications && ( isHighlightByRule || loudByRule || isDM ) ) {
309355 const avatarMxc =
310356 room . getAvatarFallbackMember ( ) ?. getMxcAvatarUrl ( ) ?? room . getMxcAvatarUrl ( ) ;
311357 const roomAvatar = avatarMxc
@@ -316,21 +362,22 @@ function MessageNotifications() {
316362 getMxIdLocalPart ( sender ) ??
317363 sender ;
318364 const content = mEvent . getContent ( ) ;
365+ // Events reaching here are already decrypted (m.room.encrypted is skipped
366+ // above). Pass isEncryptedRoom:false so the preview always shows the actual
367+ // message body when showMessageContent is enabled.
319368 const previewText = resolveNotificationPreviewText ( {
320369 content : mEvent . getContent ( ) ,
321370 eventType : mEvent . getType ( ) ,
322- isEncryptedRoom,
371+ isEncryptedRoom : false ,
323372 showMessageContent,
324373 showEncryptedMessageContent,
325374 } ) ;
326375
327376 // Build a rich ReactNode body using the same HTML parser as the room
328- // timeline — this gives full mxc image transforms, mention pills,
329- // linkify, spoilers, code blocks, etc.
377+ // timeline — mxc images, mention pills, linkify, spoilers, code blocks.
330378 let bodyNode : ReactNode ;
331379 if (
332380 showMessageContent &&
333- ( ! isEncryptedRoom || showEncryptedMessageContent ) &&
334381 content . format === 'org.matrix.custom.html' &&
335382 content . formatted_body
336383 ) {
@@ -347,7 +394,7 @@ function MessageNotifications() {
347394 roomAvatar,
348395 username : resolvedSenderName ,
349396 previewText,
350- silent : ! notificationSound ,
397+ silent : ! notificationSound || ! loudByRule ,
351398 eventId,
352399 } ) ;
353400 const { roomId } = room ;
@@ -371,45 +418,8 @@ function MessageNotifications() {
371418 } ) ;
372419 }
373420
374- // On desktop: also fire an OS notification so the user is alerted even
375- // if the browser window is minimised (respects System Notifications toggle).
376- if ( ! mobileOrTablet ( ) && showSystemNotifications && notificationPermission ( 'granted' ) ) {
377- const isEncryptedRoom = ! ! getStateEvent ( room , StateEvent . RoomEncryption ) ;
378- const avatarMxc =
379- room . getAvatarFallbackMember ( ) ?. getMxcAvatarUrl ( ) ?? room . getMxcAvatarUrl ( ) ;
380- const osPayload = buildRoomMessageNotification ( {
381- roomName : room . name ?? 'Unknown' ,
382- roomAvatar : avatarMxc
383- ? ( mxcUrlToHttp ( mx , avatarMxc , useAuthentication , 96 , 96 , 'crop' ) ?? undefined )
384- : undefined ,
385- username :
386- getMemberDisplayName ( room , sender , nicknamesRef . current ) ??
387- getMxIdLocalPart ( sender ) ??
388- sender ,
389- previewText : resolveNotificationPreviewText ( {
390- content : mEvent . getContent ( ) ,
391- eventType : mEvent . getType ( ) ,
392- isEncryptedRoom,
393- showMessageContent,
394- showEncryptedMessageContent,
395- } ) ,
396- // Play sound only if the push rule requests it and the user has sounds enabled.
397- silent : ! notificationSound || ! loudByRule ,
398- eventId,
399- } ) ;
400- const noti = new window . Notification ( osPayload . title , osPayload . options ) ;
401- const { roomId } = room ;
402- noti . onclick = ( ) => {
403- window . focus ( ) ;
404- setPending ( { roomId, eventId, targetSessionId : mx . getUserId ( ) ?? undefined } ) ;
405- noti . close ( ) ;
406- } ;
407- }
408-
409421 // In-app audio: play whenever notification sounds are enabled.
410- // Not gated on loudByRule — that only controls the OS-level silent flag.
411- // Audio API requires a visible, focused document; skip when hidden.
412- if ( document . visibilityState === 'visible' && notificationSound ) {
422+ if ( notificationSound ) {
413423 playSound ( ) ;
414424 }
415425 } ;
@@ -497,8 +507,6 @@ export function HandleNotificationClick() {
497507}
498508
499509function SyncNotificationSettingsWithServiceWorker ( ) {
500- const [ notificationSound ] = useSetting ( settingsAtom , 'isNotificationSounds' ) ;
501- const [ usePushNotifications ] = useSetting ( settingsAtom , 'usePushNotifications' ) ;
502510 const [ showMessageContent ] = useSetting ( settingsAtom , 'showMessageContentInNotifications' ) ;
503511 const [ showEncryptedMessageContent ] = useSetting (
504512 settingsAtom ,
@@ -524,9 +532,11 @@ function SyncNotificationSettingsWithServiceWorker() {
524532
525533 useEffect ( ( ) => {
526534 if ( ! ( 'serviceWorker' in navigator ) ) return ;
535+ // notificationSoundEnabled is intentionally excluded: push notification sound
536+ // is governed by the push rule's tweakSound alone (OS/Sygnal handles it).
537+ // The in-app sound setting only controls the in-page <audio> playback above.
527538 const payload = {
528539 type : 'setNotificationSettings' as const ,
529- notificationSoundEnabled : notificationSound ,
530540 showMessageContent,
531541 showEncryptedMessageContent,
532542 clearNotificationsOnRead,
@@ -536,13 +546,7 @@ function SyncNotificationSettingsWithServiceWorker() {
536546 navigator . serviceWorker . ready . then ( ( registration ) => {
537547 registration . active ?. postMessage ( payload ) ;
538548 } ) ;
539- } , [
540- notificationSound ,
541- usePushNotifications ,
542- showMessageContent ,
543- showEncryptedMessageContent ,
544- clearNotificationsOnRead ,
545- ] ) ;
549+ } , [ showMessageContent , showEncryptedMessageContent , clearNotificationsOnRead ] ) ;
546550
547551 return null ;
548552}
0 commit comments