diff --git a/frontend/src/components/chat/ChatMessageView.tsx b/frontend/src/components/chat/ChatMessageView.tsx index 21a6d01..85c27fd 100644 --- a/frontend/src/components/chat/ChatMessageView.tsx +++ b/frontend/src/components/chat/ChatMessageView.tsx @@ -17,6 +17,16 @@ const ChatMessageView: React.FC = () => { const queryClient = useQueryClient(); const messagesEndRef = useRef(null); const [input, setInput] = useState(''); + const prevActiveRef = useRef(false); + + // Invalidate messages immediately when this room + panel becomes active + useEffect(() => { + const active = !!selectedRoomToken && chatPanelOpen; + if (active && !prevActiveRef.current) { + queryClient.invalidateQueries({ queryKey: ['nextcloud', 'messages', selectedRoomToken] }); + } + prevActiveRef.current = active; + }, [selectedRoomToken, chatPanelOpen, queryClient]); const room = rooms.find((r) => r.token === selectedRoomToken); diff --git a/frontend/src/components/shared/NotificationBell.tsx b/frontend/src/components/shared/NotificationBell.tsx index b8e91a2..eab4e8c 100644 --- a/frontend/src/components/shared/NotificationBell.tsx +++ b/frontend/src/components/shared/NotificationBell.tsx @@ -26,6 +26,27 @@ import type { Notification, NotificationSchwere } from '../../types/notification const POLL_INTERVAL_MS = 15_000; // 15 seconds +function playNotificationSound() { + try { + const ctx = new AudioContext(); + const oscillator = ctx.createOscillator(); + const gain = ctx.createGain(); + oscillator.connect(gain); + gain.connect(ctx.destination); + oscillator.type = 'sine'; + oscillator.frequency.value = 600; + const now = ctx.currentTime; + gain.gain.setValueAtTime(0, now); + gain.gain.linearRampToValueAtTime(0.3, now + 0.02); + gain.gain.linearRampToValueAtTime(0, now + 0.15); + oscillator.start(now); + oscillator.stop(now + 0.15); + oscillator.onended = () => ctx.close(); + } catch { + // Audio blocked before first user interaction — fail silently + } +} + /** * Only allow window.open for URLs whose origin matches the current app origin. * External-looking URLs (different host or protocol-relative) are rejected to @@ -83,6 +104,9 @@ const NotificationBell: React.FC = () => { // Find notifications we haven't seen before const newOnes = unread.filter((n) => !knownIdsRef.current!.has(n.id)); + if (newOnes.length > 0) { + playNotificationSound(); + } newOnes.forEach((n) => { knownIdsRef.current!.add(n.id); const severity = n.schwere === 'fehler' ? 'error' : n.schwere === 'warnung' ? 'warning' : 'info'; diff --git a/frontend/src/contexts/ChatContext.tsx b/frontend/src/contexts/ChatContext.tsx index a2c91fb..75973c7 100644 --- a/frontend/src/contexts/ChatContext.tsx +++ b/frontend/src/contexts/ChatContext.tsx @@ -1,5 +1,5 @@ -import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import React, { createContext, useContext, useState, useCallback, useEffect, useRef, ReactNode } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { nextcloudApi } from '../services/nextcloud'; import { useLayout } from './LayoutContext'; import type { NextcloudConversation } from '../types/nextcloud.types'; @@ -21,6 +21,17 @@ interface ChatProviderProps { export const ChatProvider: React.FC = ({ children }) => { const [selectedRoomToken, setSelectedRoomToken] = useState(null); const { chatPanelOpen } = useLayout(); + const queryClient = useQueryClient(); + const prevPanelOpenRef = useRef(chatPanelOpen); + + // Invalidate rooms/connection when panel opens so data is fresh immediately + useEffect(() => { + if (chatPanelOpen && !prevPanelOpenRef.current) { + queryClient.invalidateQueries({ queryKey: ['nextcloud', 'connection'] }); + queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] }); + } + prevPanelOpenRef.current = chatPanelOpen; + }, [chatPanelOpen, queryClient]); const { data: connData } = useQuery({ queryKey: ['nextcloud', 'connection'], diff --git a/sync/src/db.ts b/sync/src/db.ts index b626076..1f454a5 100644 --- a/sync/src/db.ts +++ b/sync/src/db.ts @@ -69,7 +69,9 @@ export async function syncToDatabase( const profileResult = await client.query<{ user_id: string }>( `SELECT mp.user_id FROM mitglieder_profile mp - WHERE mp.fdisk_standesbuch_nr = $1`, + JOIN users u ON u.id = mp.user_id + WHERE mp.fdisk_standesbuch_nr = $1 + AND u.last_login_at IS NOT NULL`, [member.standesbuchNr] ); @@ -78,18 +80,20 @@ export async function syncToDatabase( if (profileResult.rows.length > 0) { userId = profileResult.rows[0].user_id; } else { - // Fallback: match by name (case-insensitive) + // Fallback: match by name (case-insensitive), only logged-in users const nameResult = await client.query<{ id: string }>( `SELECT u.id FROM users u JOIN mitglieder_profile mp ON mp.user_id = u.id WHERE LOWER(u.given_name) = LOWER($1) AND LOWER(u.family_name) = LOWER($2) - LIMIT 1`, + AND u.last_login_at IS NOT NULL`, [member.vorname, member.zuname] ); - if (nameResult.rows.length > 0) { + if (nameResult.rows.length > 1) { + log(`WARN: skipping ${member.vorname} ${member.zuname} (Standesbuch-Nr ${member.standesbuchNr}) — duplicate name match (${nameResult.rows.length} users)`); + } else if (nameResult.rows.length === 1) { userId = nameResult.rows[0].id; // Store the Standesbuch-Nr now that we found a match await client.query(