mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-14 22:52:15 +02:00
refactor: improve InboxSidebar and useInbox hook
- Added LayoutGrid icon to the "All connectors" option in the InboxSidebar for better visual representation. - Simplified the return statement in getConnectorTypeDisplayName for improved readability. - Refactored filter and drawer components in InboxSidebar for cleaner code structure. - Removed unused totalUnreadCount state from useInbox hook and replaced it with a memoized calculation for better performance. - Implemented optimistic updates for marking inbox items as read, enhancing user experience with immediate feedback.
This commit is contained in:
parent
be7ba76417
commit
c98cfac49f
2 changed files with 89 additions and 101 deletions
|
|
@ -9,6 +9,7 @@ import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
History,
|
History,
|
||||||
Inbox,
|
Inbox,
|
||||||
|
LayoutGrid,
|
||||||
ListFilter,
|
ListFilter,
|
||||||
Search,
|
Search,
|
||||||
X,
|
X,
|
||||||
|
|
@ -95,7 +96,13 @@ function getConnectorTypeDisplayName(connectorType: string): string {
|
||||||
BAIDU_SEARCH_API: "Baidu",
|
BAIDU_SEARCH_API: "Baidu",
|
||||||
};
|
};
|
||||||
|
|
||||||
return displayNames[connectorType] || connectorType.replace(/_/g, " ").replace(/CONNECTOR|API/gi, "").trim();
|
return (
|
||||||
|
displayNames[connectorType] ||
|
||||||
|
connectorType
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.replace(/CONNECTOR|API/gi, "")
|
||||||
|
.trim()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type InboxTab = "mentions" | "status";
|
type InboxTab = "mentions" | "status";
|
||||||
|
|
@ -142,7 +149,7 @@ export function InboxSidebar({
|
||||||
// Drawer state for filter menu (mobile only)
|
// Drawer state for filter menu (mobile only)
|
||||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||||||
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
||||||
|
|
||||||
// Prefetch trigger ref - placed on item near the end
|
// Prefetch trigger ref - placed on item near the end
|
||||||
const prefetchTriggerRef = useRef<HTMLDivElement>(null);
|
const prefetchTriggerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
@ -239,8 +246,7 @@ export function InboxSidebar({
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
items = items.filter(
|
items = items.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.title.toLowerCase().includes(query) ||
|
item.title.toLowerCase().includes(query) || item.message.toLowerCase().includes(query)
|
||||||
item.message.toLowerCase().includes(query)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -453,15 +459,14 @@ export function InboxSidebar({
|
||||||
<span className="sr-only">{t("filter") || "Filter"}</span>
|
<span className="sr-only">{t("filter") || "Filter"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="z-80">
|
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
|
||||||
{t("filter") || "Filter"}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Drawer open={filterDrawerOpen} onOpenChange={setFilterDrawerOpen} shouldScaleBackground={false}>
|
<Drawer
|
||||||
<DrawerContent
|
open={filterDrawerOpen}
|
||||||
className="max-h-[70vh] z-80"
|
onOpenChange={setFilterDrawerOpen}
|
||||||
overlayClassName="z-80"
|
shouldScaleBackground={false}
|
||||||
>
|
>
|
||||||
|
<DrawerContent className="max-h-[70vh] z-80" overlayClassName="z-80">
|
||||||
<DrawerHandle />
|
<DrawerHandle />
|
||||||
<DrawerHeader className="px-4 pb-3 pt-2">
|
<DrawerHeader className="px-4 pb-3 pt-2">
|
||||||
<DrawerTitle className="flex items-center gap-2 text-base font-semibold">
|
<DrawerTitle className="flex items-center gap-2 text-base font-semibold">
|
||||||
|
|
@ -484,7 +489,9 @@ export function InboxSidebar({
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||||
activeFilter === "all" ? "bg-primary/10 text-primary" : "hover:bg-muted"
|
activeFilter === "all"
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
|
|
@ -501,7 +508,9 @@ export function InboxSidebar({
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||||
activeFilter === "unread" ? "bg-primary/10 text-primary" : "hover:bg-muted"
|
activeFilter === "unread"
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
|
|
@ -527,10 +536,15 @@ export function InboxSidebar({
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||||
selectedConnector === null ? "bg-primary/10 text-primary" : "hover:bg-muted"
|
selectedConnector === null
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span>{t("all_connectors") || "All connectors"}</span>
|
<span className="flex items-center gap-2">
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
<span>{t("all_connectors") || "All connectors"}</span>
|
||||||
|
</span>
|
||||||
{selectedConnector === null && <Check className="h-4 w-4" />}
|
{selectedConnector === null && <Check className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
{uniqueConnectorTypes.map((connector) => (
|
{uniqueConnectorTypes.map((connector) => (
|
||||||
|
|
@ -543,14 +557,18 @@ export function InboxSidebar({
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||||
selectedConnector === connector.type ? "bg-primary/10 text-primary" : "hover:bg-muted"
|
selectedConnector === connector.type
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
{getConnectorIcon(connector.type, "h-4 w-4")}
|
{getConnectorIcon(connector.type, "h-4 w-4")}
|
||||||
<span>{connector.displayName}</span>
|
<span>{connector.displayName}</span>
|
||||||
</span>
|
</span>
|
||||||
{selectedConnector === connector.type && <Check className="h-4 w-4" />}
|
{selectedConnector === connector.type && (
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -569,21 +587,18 @@ export function InboxSidebar({
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-full"
|
|
||||||
>
|
|
||||||
<ListFilter className="h-4 w-4 text-muted-foreground" />
|
<ListFilter className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="sr-only">{t("filter") || "Filter"}</span>
|
<span className="sr-only">{t("filter") || "Filter"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="z-80">
|
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
|
||||||
{t("filter") || "Filter"}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DropdownMenuContent align="end" className={cn("z-80", activeTab === "status" ? "w-52" : "w-44")}>
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
className={cn("z-80", activeTab === "status" ? "w-52" : "w-44")}
|
||||||
|
>
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
|
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
|
||||||
{t("filter") || "Filter"}
|
{t("filter") || "Filter"}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
|
@ -616,7 +631,10 @@ export function InboxSidebar({
|
||||||
onClick={() => setSelectedConnector(null)}
|
onClick={() => setSelectedConnector(null)}
|
||||||
className="flex items-center justify-between"
|
className="flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<span>{t("all_connectors") || "All connectors"}</span>
|
<span className="flex items-center gap-2">
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
<span>{t("all_connectors") || "All connectors"}</span>
|
||||||
|
</span>
|
||||||
{selectedConnector === null && <Check className="h-4 w-4" />}
|
{selectedConnector === null && <Check className="h-4 w-4" />}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{uniqueConnectorTypes.map((connector) => (
|
{uniqueConnectorTypes.map((connector) => (
|
||||||
|
|
@ -629,7 +647,9 @@ export function InboxSidebar({
|
||||||
{getConnectorIcon(connector.type, "h-4 w-4")}
|
{getConnectorIcon(connector.type, "h-4 w-4")}
|
||||||
<span>{connector.displayName}</span>
|
<span>{connector.displayName}</span>
|
||||||
</span>
|
</span>
|
||||||
{selectedConnector === connector.type && <Check className="h-4 w-4" />}
|
{selectedConnector === connector.type && (
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|
@ -723,7 +743,8 @@ export function InboxSidebar({
|
||||||
{filteredItems.map((item, index) => {
|
{filteredItems.map((item, index) => {
|
||||||
const isMarkingAsRead = markingAsReadId === item.id;
|
const isMarkingAsRead = markingAsReadId === item.id;
|
||||||
// Place prefetch trigger on 5th item from end (only if not searching)
|
// Place prefetch trigger on 5th item from end (only if not searching)
|
||||||
const isPrefetchTrigger = !searchQuery && hasMore && index === filteredItems.length - 5;
|
const isPrefetchTrigger =
|
||||||
|
!searchQuery && hasMore && index === filteredItems.length - 5;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -802,9 +823,7 @@ export function InboxSidebar({
|
||||||
) : (
|
) : (
|
||||||
<History className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
<History className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">{getEmptyStateMessage().title}</p>
|
||||||
{getEmptyStateMessage().title}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||||
{getEmptyStateMessage().hint}
|
{getEmptyStateMessage().hint}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
|
import type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
|
||||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||||
import type { SyncHandle } from "@/lib/electric/client";
|
import type { SyncHandle } from "@/lib/electric/client";
|
||||||
|
|
@ -79,7 +79,6 @@ export function useInbox(
|
||||||
const electricClient = useElectricClient();
|
const electricClient = useElectricClient();
|
||||||
|
|
||||||
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
|
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
|
||||||
const [totalUnreadCount, setTotalUnreadCount] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
|
@ -87,9 +86,14 @@ export function useInbox(
|
||||||
|
|
||||||
const syncHandleRef = useRef<SyncHandle | null>(null);
|
const syncHandleRef = useRef<SyncHandle | null>(null);
|
||||||
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
|
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
|
||||||
const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
|
|
||||||
const userSyncKeyRef = useRef<string | null>(null);
|
const userSyncKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Calculate unread count from inboxItems (includes both recent and older when loaded)
|
||||||
|
// This ensures the count is always in sync with what's displayed
|
||||||
|
const totalUnreadCount = useMemo(() => {
|
||||||
|
return inboxItems.filter((item) => !item.read).length;
|
||||||
|
}, [inboxItems]);
|
||||||
|
|
||||||
// EFFECT 1: Electric SQL sync for real-time updates
|
// EFFECT 1: Electric SQL sync for real-time updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId || !electricClient) {
|
if (!userId || !electricClient) {
|
||||||
|
|
@ -287,69 +291,6 @@ export function useInbox(
|
||||||
};
|
};
|
||||||
}, [userId, searchSpaceId, typeFilter, electricClient]);
|
}, [userId, searchSpaceId, typeFilter, electricClient]);
|
||||||
|
|
||||||
// EFFECT 3: Unread count with live updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (!userId || !electricClient) return;
|
|
||||||
|
|
||||||
const client = electricClient;
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
async function updateUnreadCount() {
|
|
||||||
if (unreadCountLiveQueryRef.current) {
|
|
||||||
unreadCountLiveQueryRef.current.unsubscribe();
|
|
||||||
unreadCountLiveQueryRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cutoff = getSyncCutoffDate();
|
|
||||||
const query = `SELECT COUNT(*) as count FROM notifications
|
|
||||||
WHERE user_id = $1
|
|
||||||
AND (search_space_id = $2 OR search_space_id IS NULL)
|
|
||||||
AND read = false
|
|
||||||
AND created_at > '${cutoff}'`;
|
|
||||||
|
|
||||||
const result = await client.db.query<{ count: number }>(query, [userId, searchSpaceId]);
|
|
||||||
if (mounted && result.rows?.[0]) {
|
|
||||||
setTotalUnreadCount(Number(result.rows[0].count) || 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = client.db as any;
|
|
||||||
if (db.live?.query) {
|
|
||||||
const liveQuery = await db.live.query(query, [userId, searchSpaceId]);
|
|
||||||
|
|
||||||
if (!mounted) {
|
|
||||||
liveQuery.unsubscribe?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (liveQuery.subscribe) {
|
|
||||||
liveQuery.subscribe((result: { rows: { count: number }[] }) => {
|
|
||||||
if (mounted && result.rows?.[0]) {
|
|
||||||
setTotalUnreadCount(Number(result.rows[0].count) || 0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (liveQuery.unsubscribe) {
|
|
||||||
unreadCountLiveQueryRef.current = liveQuery;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[useInbox] Unread count error:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateUnreadCount();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
if (unreadCountLiveQueryRef.current) {
|
|
||||||
unreadCountLiveQueryRef.current.unsubscribe();
|
|
||||||
unreadCountLiveQueryRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [userId, searchSpaceId, electricClient]);
|
|
||||||
|
|
||||||
// loadMore - Pure cursor-based pagination, no race conditions
|
// loadMore - Pure cursor-based pagination, no race conditions
|
||||||
// Cursor is computed from current state, not stored in refs
|
// Cursor is computed from current state, not stored in refs
|
||||||
const loadMore = useCallback(async () => {
|
const loadMore = useCallback(async () => {
|
||||||
|
|
@ -408,30 +349,58 @@ export function useInbox(
|
||||||
}
|
}
|
||||||
}, [userId, searchSpaceId, typeFilter, loadingMore, hasMore, inboxItems]);
|
}, [userId, searchSpaceId, typeFilter, loadingMore, hasMore, inboxItems]);
|
||||||
|
|
||||||
// Mark inbox item as read
|
// Mark inbox item as read with optimistic update
|
||||||
const markAsRead = useCallback(async (itemId: number) => {
|
const markAsRead = useCallback(async (itemId: number) => {
|
||||||
|
// Optimistic update: mark as read immediately for instant UI feedback
|
||||||
|
setInboxItems((prev) =>
|
||||||
|
prev.map((item) => (item.id === itemId ? { ...item, read: true } : item))
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await authenticatedFetch(
|
const response = await authenticatedFetch(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${itemId}/read`,
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${itemId}/read`,
|
||||||
{ method: "PATCH" }
|
{ method: "PATCH" }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Rollback on error
|
||||||
|
setInboxItems((prev) =>
|
||||||
|
prev.map((item) => (item.id === itemId ? { ...item, read: false } : item))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// If successful, Electric SQL will sync the change and live query will update
|
||||||
|
// This ensures eventual consistency even if optimistic update was wrong
|
||||||
return response.ok;
|
return response.ok;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to mark as read:", err);
|
console.error("Failed to mark as read:", err);
|
||||||
|
// Rollback on error
|
||||||
|
setInboxItems((prev) =>
|
||||||
|
prev.map((item) => (item.id === itemId ? { ...item, read: false } : item))
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Mark all inbox items as read
|
// Mark all inbox items as read with optimistic update
|
||||||
const markAllAsRead = useCallback(async () => {
|
const markAllAsRead = useCallback(async () => {
|
||||||
|
// Optimistic update: mark all as read immediately for instant UI feedback
|
||||||
|
setInboxItems((prev) => prev.map((item) => ({ ...item, read: true })));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await authenticatedFetch(
|
const response = await authenticatedFetch(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/read-all`,
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/read-all`,
|
||||||
{ method: "PATCH" }
|
{ method: "PATCH" }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("Failed to mark all as read");
|
||||||
|
// On error, let Electric SQL sync correct the state
|
||||||
|
}
|
||||||
|
// Electric SQL will sync and live query will ensure consistency
|
||||||
return response.ok;
|
return response.ok;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to mark all as read:", err);
|
console.error("Failed to mark all as read:", err);
|
||||||
|
// On error, let Electric SQL sync correct the state
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue