Fix toast notifications, require accept_connections for senders
- Rewrite NotificationToaster with max-ID watermark for reliable new-notification detection and faster unread count polling (15s) - Block connection search and requests when sender has accept_connections disabled (backend + frontend gate) - Remove duplicate sender_settings fetch in send_connection_request - Show actionable error messages in toast respond failures Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
03fd8dba97
commit
e27beb7736
@ -88,6 +88,11 @@ async def search_user(
|
|||||||
# Always sleep to prevent timing attacks
|
# Always sleep to prevent timing attacks
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
# Sender must have accept_connections enabled to search
|
||||||
|
sender_settings = await _get_settings_for_user(db, current_user.id)
|
||||||
|
if not sender_settings or not sender_settings.accept_connections:
|
||||||
|
return UmbralSearchResponse(found=False)
|
||||||
|
|
||||||
# Don't find yourself
|
# Don't find yourself
|
||||||
if body.umbral_name == current_user.umbral_name:
|
if body.umbral_name == current_user.umbral_name:
|
||||||
return UmbralSearchResponse(found=False)
|
return UmbralSearchResponse(found=False)
|
||||||
@ -132,7 +137,15 @@ async def send_connection_request(
|
|||||||
if target.id == current_user.id:
|
if target.id == current_user.id:
|
||||||
raise HTTPException(status_code=400, detail="Cannot send a connection request to yourself")
|
raise HTTPException(status_code=400, detail="Cannot send a connection request to yourself")
|
||||||
|
|
||||||
# Check accept_connections
|
# Sender must have accept_connections enabled to participate
|
||||||
|
sender_settings = await _get_settings_for_user(db, current_user.id)
|
||||||
|
if not sender_settings or not sender_settings.accept_connections:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="You must enable 'Accept Connections' in your settings before sending requests",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check accept_connections on target
|
||||||
target_settings = await _get_settings_for_user(db, target.id)
|
target_settings = await _get_settings_for_user(db, target.id)
|
||||||
if not target_settings or not target_settings.accept_connections:
|
if not target_settings or not target_settings.accept_connections:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
@ -185,8 +198,7 @@ async def send_connection_request(
|
|||||||
db.add(conn_request)
|
db.add(conn_request)
|
||||||
await db.flush() # populate conn_request.id for source_id
|
await db.flush() # populate conn_request.id for source_id
|
||||||
|
|
||||||
# Create in-app notification for receiver
|
# Create in-app notification for receiver (sender_settings already fetched above)
|
||||||
sender_settings = await _get_settings_for_user(db, current_user.id)
|
|
||||||
sender_display = (sender_settings.preferred_name if sender_settings else None) or current_user.umbral_name
|
sender_display = (sender_settings.preferred_name if sender_settings else None) or current_user.umbral_name
|
||||||
|
|
||||||
await create_notification(
|
await create_notification(
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Search, UserPlus, Loader2, AlertCircle, CheckCircle } from 'lucide-react';
|
import { Search, UserPlus, Loader2, AlertCircle, CheckCircle, Settings } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -12,6 +13,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useConnections } from '@/hooks/useConnections';
|
import { useConnections } from '@/hooks/useConnections';
|
||||||
|
import { useSettings } from '@/hooks/useSettings';
|
||||||
import { getErrorMessage } from '@/lib/api';
|
import { getErrorMessage } from '@/lib/api';
|
||||||
|
|
||||||
interface ConnectionSearchProps {
|
interface ConnectionSearchProps {
|
||||||
@ -21,10 +23,14 @@ interface ConnectionSearchProps {
|
|||||||
|
|
||||||
export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearchProps) {
|
export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearchProps) {
|
||||||
const { search, isSearching, sendRequest, isSending } = useConnections();
|
const { search, isSearching, sendRequest, isSending } = useConnections();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [umbralName, setUmbralName] = useState('');
|
const [umbralName, setUmbralName] = useState('');
|
||||||
const [found, setFound] = useState<boolean | null>(null);
|
const [found, setFound] = useState<boolean | null>(null);
|
||||||
const [sent, setSent] = useState(false);
|
const [sent, setSent] = useState(false);
|
||||||
|
|
||||||
|
const acceptConnectionsEnabled = settings?.accept_connections ?? false;
|
||||||
|
|
||||||
const handleSearch = async () => {
|
const handleSearch = async () => {
|
||||||
if (!umbralName.trim()) return;
|
if (!umbralName.trim()) return;
|
||||||
setFound(null);
|
setFound(null);
|
||||||
@ -67,6 +73,24 @@ export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearc
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 pt-2">
|
<div className="space-y-4 pt-2">
|
||||||
|
{!acceptConnectionsEnabled ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-4 text-center">
|
||||||
|
<AlertCircle className="h-8 w-8 text-amber-400" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You need to enable <span className="text-foreground font-medium">Accept Connections</span> in your settings before you can send or receive connection requests.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1.5"
|
||||||
|
onClick={() => { handleClose(); navigate('/settings'); }}
|
||||||
|
>
|
||||||
|
<Settings className="h-3.5 w-3.5" />
|
||||||
|
Go to Settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="umbral_search">Umbral Name</Label>
|
<Label htmlFor="umbral_search">Umbral Name</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@ -135,6 +159,8 @@ export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearc
|
|||||||
Connection request sent
|
Connection request sent
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@ -3,14 +3,15 @@ import { toast } from 'sonner';
|
|||||||
import { Check, X, Bell, UserPlus } from 'lucide-react';
|
import { Check, X, Bell, UserPlus } from 'lucide-react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNotifications } from '@/hooks/useNotifications';
|
import { useNotifications } from '@/hooks/useNotifications';
|
||||||
import api from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
import type { AppNotification } from '@/types';
|
import type { AppNotification } from '@/types';
|
||||||
|
|
||||||
export default function NotificationToaster() {
|
export default function NotificationToaster() {
|
||||||
const { notifications } = useNotifications();
|
const { notifications, unreadCount } = useNotifications();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const seenIdsRef = useRef(new Set<number>());
|
const maxSeenIdRef = useRef(0);
|
||||||
const initializedRef = useRef(false);
|
const initializedRef = useRef(false);
|
||||||
|
const prevUnreadRef = useRef(0);
|
||||||
|
|
||||||
const handleConnectionRespond = useCallback(
|
const handleConnectionRespond = useCallback(
|
||||||
async (requestId: number, action: 'accept' | 'reject', toastId: string | number) => {
|
async (requestId: number, action: 'accept' | 'reject', toastId: string | number) => {
|
||||||
@ -21,33 +22,45 @@ export default function NotificationToaster() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['connections'] });
|
queryClient.invalidateQueries({ queryKey: ['connections'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['people'] });
|
queryClient.invalidateQueries({ queryKey: ['people'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||||
} catch {
|
} catch (err) {
|
||||||
toast.dismiss(toastId);
|
toast.dismiss(toastId);
|
||||||
toast.error('Failed to respond to request');
|
toast.error(getErrorMessage(err, 'Failed to respond to request'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[queryClient],
|
[queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Track unread count changes to force-refetch the list
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!notifications.length && !initializedRef.current) return;
|
if (unreadCount > prevUnreadRef.current && initializedRef.current) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['notifications', 'list'] });
|
||||||
|
}
|
||||||
|
prevUnreadRef.current = unreadCount;
|
||||||
|
}, [unreadCount, queryClient]);
|
||||||
|
|
||||||
// On first load, record all existing IDs without toasting
|
// Show toasts for new notifications (ID > max seen)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!notifications.length) return;
|
||||||
|
|
||||||
|
// On first load, record the max ID without toasting
|
||||||
if (!initializedRef.current) {
|
if (!initializedRef.current) {
|
||||||
notifications.forEach((n) => seenIdsRef.current.add(n.id));
|
maxSeenIdRef.current = Math.max(...notifications.map((n) => n.id));
|
||||||
initializedRef.current = true;
|
initializedRef.current = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find new notifications we haven't seen
|
// Find unread notifications with IDs higher than our watermark
|
||||||
const newNotifications = notifications.filter(
|
const newNotifications = notifications.filter(
|
||||||
(n) => !n.is_read && !seenIdsRef.current.has(n.id),
|
(n) => !n.is_read && n.id > maxSeenIdRef.current,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Record all current IDs
|
// Advance watermark
|
||||||
notifications.forEach((n) => seenIdsRef.current.add(n.id));
|
const maxCurrent = Math.max(...notifications.map((n) => n.id));
|
||||||
|
if (maxCurrent > maxSeenIdRef.current) {
|
||||||
|
maxSeenIdRef.current = maxCurrent;
|
||||||
|
}
|
||||||
|
|
||||||
// Show toasts for new notifications
|
// Show toasts
|
||||||
newNotifications.forEach((notification) => {
|
newNotifications.forEach((notification) => {
|
||||||
if (notification.type === 'connection_request' && notification.source_id) {
|
if (notification.type === 'connection_request' && notification.source_id) {
|
||||||
showConnectionRequestToast(notification);
|
showConnectionRequestToast(notification);
|
||||||
@ -63,8 +76,6 @@ export default function NotificationToaster() {
|
|||||||
|
|
||||||
const showConnectionRequestToast = (notification: AppNotification) => {
|
const showConnectionRequestToast = (notification: AppNotification) => {
|
||||||
const requestId = notification.source_id!;
|
const requestId = notification.source_id!;
|
||||||
const senderName =
|
|
||||||
(notification.data as Record<string, string>)?.sender_umbral_name || 'Someone';
|
|
||||||
|
|
||||||
toast.custom(
|
toast.custom(
|
||||||
(id) => (
|
(id) => (
|
||||||
@ -76,7 +87,7 @@ export default function NotificationToaster() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-foreground">Connection Request</p>
|
<p className="text-sm font-medium text-foreground">Connection Request</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{notification.message || `${senderName} wants to connect with you`}
|
{notification.message || 'Someone wants to connect with you'}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 mt-3">
|
<div className="flex items-center gap-2 mt-3">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import type { NotificationListResponse } from '@/types';
|
|||||||
export function useNotifications() {
|
export function useNotifications() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const visibleRef = useRef(true);
|
const visibleRef = useRef(true);
|
||||||
const prevUnreadRef = useRef<number | undefined>(undefined);
|
|
||||||
|
|
||||||
// Track tab visibility to pause polling when hidden
|
// Track tab visibility to pause polling when hidden
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -23,20 +22,10 @@ export function useNotifications() {
|
|||||||
const { data } = await api.get<{ count: number }>('/notifications/unread-count');
|
const { data } = await api.get<{ count: number }>('/notifications/unread-count');
|
||||||
return data.count;
|
return data.count;
|
||||||
},
|
},
|
||||||
refetchInterval: () => (visibleRef.current ? 30_000 : false),
|
refetchInterval: () => (visibleRef.current ? 15_000 : false),
|
||||||
staleTime: 15_000,
|
staleTime: 10_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// When unread count increases, immediately refetch the notification list
|
|
||||||
useEffect(() => {
|
|
||||||
const count = unreadQuery.data;
|
|
||||||
if (count === undefined) return;
|
|
||||||
if (prevUnreadRef.current !== undefined && count > prevUnreadRef.current) {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['notifications', 'list'] });
|
|
||||||
}
|
|
||||||
prevUnreadRef.current = count;
|
|
||||||
}, [unreadQuery.data, queryClient]);
|
|
||||||
|
|
||||||
const listQuery = useQuery({
|
const listQuery = useQuery({
|
||||||
queryKey: ['notifications', 'list'],
|
queryKey: ['notifications', 'list'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user