mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 03:32:54 -05:00
Compare commits
5 Commits
react-quer
...
enhance/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
148d3759bf | ||
|
|
d480065924 | ||
|
|
8bf59fe1e5 | ||
|
|
24fd50ab3b | ||
|
|
dabad3258e |
@@ -31,6 +31,7 @@ import { LoadingIndicator } from './reports/LoadingIndicator';
|
||||
import { NarrowAlternate, WideComponent } from './responsive';
|
||||
import { UserDirectoryPage } from './responsive/wide';
|
||||
import { ScrollProvider } from './ScrollProvider';
|
||||
import { ScrollRestoreProvider } from './ScrollRestore';
|
||||
import { useMultiuserEnabled } from './ServerContext';
|
||||
import { Settings } from './settings';
|
||||
import { FloatableSidebar } from './sidebar';
|
||||
@@ -202,10 +203,11 @@ export function FinancesApp() {
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<ScrollProvider
|
||||
isDisabled={!isNarrowWidth}
|
||||
scrollableRef={scrollableRef}
|
||||
>
|
||||
<ScrollRestoreProvider>
|
||||
<ScrollProvider
|
||||
isDisabled={!isNarrowWidth}
|
||||
scrollableRef={scrollableRef}
|
||||
>
|
||||
<View
|
||||
ref={scrollableRef}
|
||||
style={{
|
||||
@@ -338,6 +340,7 @@ export function FinancesApp() {
|
||||
<Route path="*" element={null} />
|
||||
</Routes>
|
||||
</ScrollProvider>
|
||||
</ScrollRestoreProvider>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
183
packages/desktop-client/src/components/ScrollRestore.tsx
Normal file
183
packages/desktop-client/src/components/ScrollRestore.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import React, {
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
|
||||
import debounce from 'debounce';
|
||||
|
||||
type ScrollPosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type ScrollRestoreContextValue = {
|
||||
registerScrollElement: (key: string, element: HTMLElement) => () => void;
|
||||
navigate: ReturnType<typeof useNavigate>;
|
||||
};
|
||||
|
||||
const ScrollRestoreContext = createContext<ScrollRestoreContextValue | undefined>(undefined);
|
||||
|
||||
type ScrollRestoreProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function ScrollRestoreProvider({ children }: ScrollRestoreProviderProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const scrollElements = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const originalNavigate = useRef(navigate);
|
||||
|
||||
// Update navigate ref when it changes
|
||||
useEffect(() => {
|
||||
originalNavigate.current = navigate;
|
||||
}, [navigate]);
|
||||
|
||||
// Create a custom navigate function that saves scroll positions before navigating
|
||||
const enhancedNavigate = useCallback((to: any, options: any = {}) => {
|
||||
// Get current scroll positions
|
||||
const scrollPositions: Record<string, ScrollPosition> = {};
|
||||
scrollElements.current.forEach((element, key) => {
|
||||
scrollPositions[key] = {
|
||||
x: element.scrollLeft,
|
||||
y: element.scrollTop,
|
||||
};
|
||||
});
|
||||
|
||||
// If we have scroll positions, update the current location state
|
||||
if (Object.keys(scrollPositions).length > 0) {
|
||||
// First, update current location with scroll positions
|
||||
originalNavigate.current(location.pathname + location.search, {
|
||||
replace: true,
|
||||
state: {
|
||||
...location.state,
|
||||
scrollPositions,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Then navigate to the new location
|
||||
originalNavigate.current(to, options);
|
||||
}, [location, originalNavigate]);
|
||||
|
||||
// Restore scroll positions when coming to a page
|
||||
useEffect(() => {
|
||||
const savedPositions = location.state?.scrollPositions;
|
||||
|
||||
if (!savedPositions || typeof savedPositions !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
let attemptCount = 0;
|
||||
const maxAttempts = 20; // Allow more attempts for loading states
|
||||
|
||||
const restoreScrollPositions = () => {
|
||||
attemptCount++;
|
||||
|
||||
// Check if we have any registered elements that match the saved positions
|
||||
const savedKeys = Object.keys(savedPositions as Record<string, ScrollPosition>);
|
||||
const hasRegisteredElements = savedKeys.some(key => scrollElements.current.has(key));
|
||||
|
||||
if (!hasRegisteredElements) {
|
||||
if (attemptCount < maxAttempts) {
|
||||
// Elements not registered yet, try again with increasing delay for loading states
|
||||
const delay = attemptCount < 5 ? 50 : attemptCount < 10 ? 150 : 300;
|
||||
setTimeout(restoreScrollPositions, delay);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for next frame to ensure elements are fully rendered
|
||||
requestAnimationFrame(() => {
|
||||
Object.entries(savedPositions as Record<string, ScrollPosition>).forEach(([key, position]) => {
|
||||
const element = scrollElements.current.get(key);
|
||||
if (element && typeof position === 'object' && 'x' in position && 'y' in position) {
|
||||
element.scrollTo(position.x, position.y);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Start restoration after a small delay to allow initial mounting
|
||||
const timeoutId = setTimeout(restoreScrollPositions, 10);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [location.state?.scrollPositions]);
|
||||
|
||||
const registerScrollElement = useCallback((key: string, element: HTMLElement) => {
|
||||
scrollElements.current.set(key, element);
|
||||
|
||||
return () => {
|
||||
scrollElements.current.delete(key);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
registerScrollElement,
|
||||
navigate: enhancedNavigate,
|
||||
}),
|
||||
[registerScrollElement, enhancedNavigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollRestoreContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ScrollRestoreContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
type ScrollRestoreProps = {
|
||||
scrollKey?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function ScrollRestore({ scrollKey = 'default', children }: ScrollRestoreProps) {
|
||||
const context = useContext(ScrollRestoreContext);
|
||||
const scrollElementRef = useRef<HTMLElement>(null);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('ScrollRestore must be used within a ScrollRestoreProvider');
|
||||
}
|
||||
|
||||
const { registerScrollElement } = context;
|
||||
|
||||
useEffect(() => {
|
||||
const element = scrollElementRef.current;
|
||||
if (element) {
|
||||
return registerScrollElement(scrollKey, element);
|
||||
}
|
||||
}, [registerScrollElement, scrollKey]);
|
||||
|
||||
// Clone the child element and add ref
|
||||
const childElement = React.Children.only(children) as React.ReactElement<any>;
|
||||
|
||||
return React.cloneElement(childElement, {
|
||||
innerRef: (ref: HTMLElement) => {
|
||||
scrollElementRef.current = ref;
|
||||
|
||||
// Call original innerRef if it exists
|
||||
const originalRef = childElement.props.innerRef;
|
||||
if (typeof originalRef === 'function') {
|
||||
originalRef(ref);
|
||||
} else if (originalRef && typeof originalRef === 'object') {
|
||||
(originalRef as RefObject<HTMLElement>).current = ref;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useScrollRestore() {
|
||||
const context = useContext(ScrollRestoreContext);
|
||||
if (!context) {
|
||||
throw new Error('useScrollRestore must be used within a ScrollRestoreProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { styles } from '@actual-app/components/styles';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { ScrollRestore } from '../ScrollRestore';
|
||||
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import {
|
||||
type CategoryEntity,
|
||||
@@ -91,7 +93,7 @@ export function BudgetTable(props: BudgetTableProps) {
|
||||
);
|
||||
const [categoryExpandedStatePref] = useGlobalPref('categoryExpandedState');
|
||||
const categoryExpandedState = categoryExpandedStatePref ?? 0;
|
||||
const [editing, setEditing] = useState<{ id: string; cell: string } | null>(
|
||||
const [editing, setEditing] = useState<{ id: string; cell: string } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
@@ -229,6 +231,8 @@ export function BudgetTable(props: BudgetTableProps) {
|
||||
onCollapse(categoryGroups.map(g => g.id));
|
||||
};
|
||||
|
||||
|
||||
|
||||
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
|
||||
|
||||
return (
|
||||
@@ -280,15 +284,16 @@ export function BudgetTable(props: BudgetTableProps) {
|
||||
expandAllCategories={expandAllCategories}
|
||||
collapseAllCategories={collapseAllCategories}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
overflowY: 'scroll',
|
||||
overflowAnchor: 'none',
|
||||
flex: 1,
|
||||
paddingLeft: 5,
|
||||
paddingRight: 5,
|
||||
}}
|
||||
>
|
||||
<ScrollRestore scrollKey="budget-table">
|
||||
<View
|
||||
style={{
|
||||
overflowY: 'scroll',
|
||||
overflowAnchor: 'none',
|
||||
flex: 1,
|
||||
paddingLeft: 5,
|
||||
paddingRight: 5,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
@@ -315,7 +320,8 @@ export function BudgetTable(props: BudgetTableProps) {
|
||||
/>
|
||||
</SchedulesProvider>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollRestore>
|
||||
</MonthsProvider>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -51,9 +51,9 @@ import {
|
||||
} from '@desktop-client/components/table';
|
||||
import { useCategoryScheduleGoalTemplateIndicator } from '@desktop-client/hooks/useCategoryScheduleGoalTemplateIndicator';
|
||||
import { useContextMenu } from '@desktop-client/hooks/useContextMenu';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { useUndo } from '@desktop-client/hooks/useUndo';
|
||||
import { envelopeBudget } from '@desktop-client/queries/queries';
|
||||
import { useScrollRestore } from '@desktop-client/components/ScrollRestore';
|
||||
|
||||
export function useEnvelopeSheetName<
|
||||
FieldName extends SheetFields<'envelope-budget'>,
|
||||
@@ -251,7 +251,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
|
||||
|
||||
const { showUndoNotification } = useUndo();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { navigate } = useScrollRestore();
|
||||
|
||||
const { schedule, scheduleStatus, isScheduleRecurring, description } =
|
||||
useCategoryScheduleGoalTemplateIndicator({
|
||||
@@ -414,6 +414,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Field name="spent" width="flex" style={{ textAlign: 'right' }}>
|
||||
<View
|
||||
data-testid="category-month-spent"
|
||||
@@ -439,7 +440,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
|
||||
? theme.warningText
|
||||
: theme.upcomingText,
|
||||
}}
|
||||
onPress={() =>
|
||||
onPress={() =>
|
||||
schedule._account
|
||||
? navigate(`/accounts/${schedule._account}`)
|
||||
: navigate('/accounts')
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
updateGroup,
|
||||
} from '@desktop-client/queries/queriesSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
import { useScrollRestore } from '../ScrollRestore';
|
||||
|
||||
type TrackingReportComponents = {
|
||||
SummaryComponent: typeof trackingBudget.BudgetSummary;
|
||||
@@ -69,7 +70,7 @@ function BudgetInner(props: BudgetInnerProps) {
|
||||
const currentMonth = monthUtils.currentMonth();
|
||||
const spreadsheet = useSpreadsheet();
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { navigate } = useScrollRestore();
|
||||
const [summaryCollapsed, setSummaryCollapsedPref] = useLocalPref(
|
||||
'budget.summaryCollapsed',
|
||||
);
|
||||
@@ -280,6 +281,7 @@ function BudgetInner(props: BudgetInnerProps) {
|
||||
type: 'date',
|
||||
},
|
||||
];
|
||||
|
||||
navigate('/accounts', {
|
||||
state: {
|
||||
goBack: true,
|
||||
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
type SheetCellProps,
|
||||
} from '@desktop-client/components/table';
|
||||
import { useCategoryScheduleGoalTemplateIndicator } from '@desktop-client/hooks/useCategoryScheduleGoalTemplateIndicator';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { useScrollRestore } from '@desktop-client/components/ScrollRestore';
|
||||
import { useUndo } from '@desktop-client/hooks/useUndo';
|
||||
import { trackingBudget } from '@desktop-client/queries/queries';
|
||||
|
||||
@@ -242,7 +242,7 @@ export const CategoryMonth = memo(function CategoryMonth({
|
||||
|
||||
const { showUndoNotification } = useUndo();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { navigate } = useScrollRestore();
|
||||
|
||||
const { schedule, scheduleStatus, isScheduleRecurring, description } =
|
||||
useCategoryScheduleGoalTemplateIndicator({
|
||||
@@ -417,7 +417,7 @@ export const CategoryMonth = memo(function CategoryMonth({
|
||||
? theme.warningText
|
||||
: theme.upcomingText,
|
||||
}}
|
||||
onPress={() =>
|
||||
onPress={() =>
|
||||
schedule._account
|
||||
? navigate(`/accounts/${schedule._account}`)
|
||||
: navigate('/accounts')
|
||||
|
||||
@@ -33,6 +33,7 @@ import { IncomeGroup } from './IncomeGroup';
|
||||
import { MOBILE_NAV_HEIGHT } from '@desktop-client/components/mobile/MobileNavTabs';
|
||||
import { PullToRefresh } from '@desktop-client/components/mobile/PullToRefresh';
|
||||
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
|
||||
import { ScrollRestore, useScrollRestore } from '@desktop-client/components/ScrollRestore';
|
||||
import { PrivacyFilter } from '@desktop-client/components/PrivacyFilter';
|
||||
import { CellValue } from '@desktop-client/components/spreadsheet/CellValue';
|
||||
import { useFormat } from '@desktop-client/components/spreadsheet/useFormat';
|
||||
@@ -41,7 +42,6 @@ import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { useLocale } from '@desktop-client/hooks/useLocale';
|
||||
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { useOverspentCategories } from '@desktop-client/hooks/useOverspentCategories';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import { useUndo } from '@desktop-client/hooks/useUndo';
|
||||
@@ -331,6 +331,8 @@ export function BudgetTable({
|
||||
'mobile.showSpentColumn',
|
||||
);
|
||||
|
||||
|
||||
|
||||
function toggleSpentColumn() {
|
||||
setShowSpentColumnPref(!showSpentColumn);
|
||||
}
|
||||
@@ -343,6 +345,8 @@ export function BudgetTable({
|
||||
|
||||
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Page
|
||||
padding={0}
|
||||
@@ -400,27 +404,30 @@ export function BudgetTable({
|
||||
onShowBudgetSummary={onShowBudgetSummary}
|
||||
/>
|
||||
<PullToRefresh onRefresh={onRefresh}>
|
||||
<View
|
||||
data-testid="budget-table"
|
||||
style={{
|
||||
backgroundColor: theme.pageBackground,
|
||||
paddingBottom: MOBILE_NAV_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<SchedulesProvider query={schedulesQuery}>
|
||||
<BudgetGroups
|
||||
type={budgetType}
|
||||
categoryGroups={categoryGroups}
|
||||
showBudgetedColumn={!showSpentColumn}
|
||||
show3Columns={show3Columns}
|
||||
showHiddenCategories={showHiddenCategories}
|
||||
month={month}
|
||||
onEditCategoryGroup={onEditCategoryGroup}
|
||||
onEditCategory={onEditCategory}
|
||||
onBudgetAction={onBudgetAction}
|
||||
/>
|
||||
</SchedulesProvider>
|
||||
</View>
|
||||
<ScrollRestore scrollKey="mobile-budget-table">
|
||||
<View
|
||||
data-testid="budget-table"
|
||||
style={{
|
||||
backgroundColor: theme.pageBackground,
|
||||
paddingBottom: MOBILE_NAV_HEIGHT,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<SchedulesProvider query={schedulesQuery}>
|
||||
<BudgetGroups
|
||||
type={budgetType}
|
||||
categoryGroups={categoryGroups}
|
||||
showBudgetedColumn={!showSpentColumn}
|
||||
show3Columns={show3Columns}
|
||||
showHiddenCategories={showHiddenCategories}
|
||||
month={month}
|
||||
onEditCategoryGroup={onEditCategoryGroup}
|
||||
onEditCategory={onEditCategory}
|
||||
onBudgetAction={onBudgetAction}
|
||||
/>
|
||||
</SchedulesProvider>
|
||||
</View>
|
||||
</ScrollRestore>
|
||||
</PullToRefresh>
|
||||
</Page>
|
||||
);
|
||||
@@ -450,7 +457,7 @@ function Banner({ type = 'info', children }) {
|
||||
|
||||
function UncategorizedTransactionsBanner(props) {
|
||||
const count = useSheetValue(uncategorizedCount());
|
||||
const navigate = useNavigate();
|
||||
const { navigate } = useScrollRestore();
|
||||
|
||||
if (count === null || count <= 0) {
|
||||
return null;
|
||||
|
||||
@@ -16,11 +16,11 @@ import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackBu
|
||||
import { AddTransactionButton } from '@desktop-client/components/mobile/transactions/AddTransactionButton';
|
||||
import { TransactionListWithBalances } from '@desktop-client/components/mobile/transactions/TransactionListWithBalances';
|
||||
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
|
||||
import { useScrollRestore } from '@desktop-client/components/ScrollRestore';
|
||||
import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
|
||||
import { useCategoryPreviewTransactions } from '@desktop-client/hooks/useCategoryPreviewTransactions';
|
||||
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
|
||||
import { useLocale } from '@desktop-client/hooks/useLocale';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { useTransactions } from '@desktop-client/hooks/useTransactions';
|
||||
import { useTransactionsSearch } from '@desktop-client/hooks/useTransactionsSearch';
|
||||
import * as queries from '@desktop-client/queries/queries';
|
||||
@@ -74,7 +74,7 @@ function TransactionListWithPreviews({
|
||||
month,
|
||||
}: TransactionListWithPreviewsProps) {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { navigate } = useScrollRestore();
|
||||
|
||||
const baseTransactionsQuery = useCallback(
|
||||
() =>
|
||||
|
||||
@@ -21,7 +21,7 @@ import { SpentCell } from './SpentCell';
|
||||
|
||||
import { useSheetValue } from '@desktop-client/components/spreadsheet/useSheetValue';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { useScrollRestore } from '@desktop-client/components/ScrollRestore';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import { useUndo } from '@desktop-client/hooks/useUndo';
|
||||
import { collapseModals, pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
@@ -386,11 +386,12 @@ export function ExpenseCategoryListItem({
|
||||
onCover,
|
||||
]);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { navigate } = useScrollRestore();
|
||||
const onShowActivity = useCallback(() => {
|
||||
if (!category) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(`/categories/${category.id}?month=${month}`);
|
||||
}, [category, month, navigate]);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { BalanceCell } from './BalanceCell';
|
||||
import { BudgetCell } from './BudgetCell';
|
||||
import { getColumnWidth, ROW_HEIGHT } from './BudgetTable';
|
||||
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { useScrollRestore } from '@desktop-client/components/ScrollRestore';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import { collapseModals, pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import {
|
||||
@@ -185,7 +185,7 @@ export function IncomeCategoryListItem({
|
||||
}: IncomeCategoryListItemProps) {
|
||||
const { value: category } = props;
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { navigate } = useScrollRestore();
|
||||
const [budgetType = 'envelope'] = useSyncedPref('budgetType');
|
||||
const balanceMenuModalName = `envelope-income-balance-menu`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user