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
|
||||
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
|
||||
if body.umbral_name == current_user.umbral_name:
|
||||
return UmbralSearchResponse(found=False)
|
||||
@ -132,7 +137,15 @@ async def send_connection_request(
|
||||
if target.id == current_user.id:
|
||||
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)
|
||||
if not target_settings or not target_settings.accept_connections:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
@ -185,8 +198,7 @@ async def send_connection_request(
|
||||
db.add(conn_request)
|
||||
await db.flush() # populate conn_request.id for source_id
|
||||
|
||||
# Create in-app notification for receiver
|
||||
sender_settings = await _get_settings_for_user(db, current_user.id)
|
||||
# Create in-app notification for receiver (sender_settings already fetched above)
|
||||
sender_display = (sender_settings.preferred_name if sender_settings else None) or current_user.umbral_name
|
||||
|
||||
await create_notification(
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
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 { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -12,6 +13,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useConnections } from '@/hooks/useConnections';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { getErrorMessage } from '@/lib/api';
|
||||
|
||||
interface ConnectionSearchProps {
|
||||
@ -21,10 +23,14 @@ interface ConnectionSearchProps {
|
||||
|
||||
export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearchProps) {
|
||||
const { search, isSearching, sendRequest, isSending } = useConnections();
|
||||
const { settings } = useSettings();
|
||||
const navigate = useNavigate();
|
||||
const [umbralName, setUmbralName] = useState('');
|
||||
const [found, setFound] = useState<boolean | null>(null);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const acceptConnectionsEnabled = settings?.accept_connections ?? false;
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!umbralName.trim()) return;
|
||||
setFound(null);
|
||||
@ -67,6 +73,24 @@ export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearc
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<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">
|
||||
<Label htmlFor="umbral_search">Umbral Name</Label>
|
||||
<div className="flex gap-2">
|
||||
@ -135,6 +159,8 @@ export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearc
|
||||
Connection request sent
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -3,14 +3,15 @@ import { toast } from 'sonner';
|
||||
import { Check, X, Bell, UserPlus } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import api from '@/lib/api';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import type { AppNotification } from '@/types';
|
||||
|
||||
export default function NotificationToaster() {
|
||||
const { notifications } = useNotifications();
|
||||
const { notifications, unreadCount } = useNotifications();
|
||||
const queryClient = useQueryClient();
|
||||
const seenIdsRef = useRef(new Set<number>());
|
||||
const maxSeenIdRef = useRef(0);
|
||||
const initializedRef = useRef(false);
|
||||
const prevUnreadRef = useRef(0);
|
||||
|
||||
const handleConnectionRespond = useCallback(
|
||||
async (requestId: number, action: 'accept' | 'reject', toastId: string | number) => {
|
||||
@ -21,33 +22,45 @@ export default function NotificationToaster() {
|
||||
queryClient.invalidateQueries({ queryKey: ['connections'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['people'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
} catch {
|
||||
} catch (err) {
|
||||
toast.dismiss(toastId);
|
||||
toast.error('Failed to respond to request');
|
||||
toast.error(getErrorMessage(err, 'Failed to respond to request'));
|
||||
}
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
// Track unread count changes to force-refetch the list
|
||||
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) {
|
||||
notifications.forEach((n) => seenIdsRef.current.add(n.id));
|
||||
maxSeenIdRef.current = Math.max(...notifications.map((n) => n.id));
|
||||
initializedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Find new notifications we haven't seen
|
||||
// Find unread notifications with IDs higher than our watermark
|
||||
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
|
||||
notifications.forEach((n) => seenIdsRef.current.add(n.id));
|
||||
// Advance watermark
|
||||
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) => {
|
||||
if (notification.type === 'connection_request' && notification.source_id) {
|
||||
showConnectionRequestToast(notification);
|
||||
@ -63,8 +76,6 @@ export default function NotificationToaster() {
|
||||
|
||||
const showConnectionRequestToast = (notification: AppNotification) => {
|
||||
const requestId = notification.source_id!;
|
||||
const senderName =
|
||||
(notification.data as Record<string, string>)?.sender_umbral_name || 'Someone';
|
||||
|
||||
toast.custom(
|
||||
(id) => (
|
||||
@ -76,7 +87,7 @@ export default function NotificationToaster() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">Connection Request</p>
|
||||
<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>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<button
|
||||
|
||||
@ -6,7 +6,6 @@ import type { NotificationListResponse } from '@/types';
|
||||
export function useNotifications() {
|
||||
const queryClient = useQueryClient();
|
||||
const visibleRef = useRef(true);
|
||||
const prevUnreadRef = useRef<number | undefined>(undefined);
|
||||
|
||||
// Track tab visibility to pause polling when hidden
|
||||
useEffect(() => {
|
||||
@ -23,20 +22,10 @@ export function useNotifications() {
|
||||
const { data } = await api.get<{ count: number }>('/notifications/unread-count');
|
||||
return data.count;
|
||||
},
|
||||
refetchInterval: () => (visibleRef.current ? 30_000 : false),
|
||||
staleTime: 15_000,
|
||||
refetchInterval: () => (visibleRef.current ? 15_000 : false),
|
||||
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({
|
||||
queryKey: ['notifications', 'list'],
|
||||
queryFn: async () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user