mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 11:42:54 -05:00
Compare commits
6 Commits
mobile/lin
...
payee-geol
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35c2623d89 | ||
|
|
cbb07ae0af | ||
|
|
bcc1f04ee6 | ||
|
|
3c8fb3447b | ||
|
|
8518ab10ec | ||
|
|
86c6cf98be |
@@ -33,6 +33,7 @@ import {
|
||||
import { ItemHeader } from './ItemHeader';
|
||||
|
||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useNearbyPayees } from '@desktop-client/hooks/useNearbyPayees';
|
||||
import { useCommonPayees, usePayees } from '@desktop-client/hooks/usePayees';
|
||||
import {
|
||||
createPayee,
|
||||
@@ -45,9 +46,16 @@ export type PayeeAutocompleteItem = PayeeEntity;
|
||||
const MAX_AUTO_SUGGESTIONS = 5;
|
||||
|
||||
function getPayeeSuggestions(
|
||||
nearbyPayees: PayeeAutocompleteItem[],
|
||||
commonPayees: PayeeAutocompleteItem[],
|
||||
payees: PayeeAutocompleteItem[],
|
||||
): (PayeeAutocompleteItem & PayeeItemType)[] {
|
||||
const nearbyPayeeSuggestions: (PayeeAutocompleteItem & PayeeItemType)[] =
|
||||
nearbyPayees.map(payee => ({
|
||||
...payee,
|
||||
itemType: determineItemType(payee, false, true),
|
||||
}));
|
||||
|
||||
const favoritePayees = payees
|
||||
.filter(p => p.favorite)
|
||||
.map(p => {
|
||||
@@ -75,14 +83,17 @@ function getPayeeSuggestions(
|
||||
.filter(p => !favoritePayees.find(fp => fp.id === p.id))
|
||||
.filter(p => !additionalCommonPayees.find(fp => fp.id === p.id))
|
||||
.map<PayeeAutocompleteItem & PayeeItemType>(p => {
|
||||
return { ...p, itemType: determineItemType(p, false) };
|
||||
return { ...p, itemType: determineItemType(p) };
|
||||
});
|
||||
|
||||
return favoritePayees.concat(additionalCommonPayees).concat(filteredPayees);
|
||||
return nearbyPayeeSuggestions
|
||||
.concat(favoritePayees)
|
||||
.concat(additionalCommonPayees)
|
||||
.concat(filteredPayees);
|
||||
}
|
||||
|
||||
return payees.map(p => {
|
||||
return { ...p, itemType: determineItemType(p, false) };
|
||||
return { ...p, itemType: determineItemType(p) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -137,20 +148,23 @@ type PayeeListProps = {
|
||||
footer: ReactNode;
|
||||
};
|
||||
|
||||
type ItemTypes = 'account' | 'payee' | 'common_payee';
|
||||
type ItemTypes = 'account' | 'payee' | 'common_payee' | 'nearby_payee';
|
||||
type PayeeItemType = {
|
||||
itemType: ItemTypes;
|
||||
};
|
||||
|
||||
function determineItemType(
|
||||
item: PayeeAutocompleteItem,
|
||||
isCommon: boolean,
|
||||
isCommon: boolean = false,
|
||||
isNearby: boolean = false,
|
||||
): ItemTypes {
|
||||
if (item.transfer_acct) {
|
||||
return 'account';
|
||||
}
|
||||
if (isCommon) {
|
||||
return 'common_payee';
|
||||
} else if (isNearby) {
|
||||
return 'nearby_payee';
|
||||
} else {
|
||||
return 'payee';
|
||||
}
|
||||
@@ -211,6 +225,8 @@ function PayeeList({
|
||||
title = t('Payees');
|
||||
} else if (itemType === 'account' && lastType !== itemType) {
|
||||
title = t('Transfer To/From');
|
||||
} else if (itemType === 'nearby_payee' && lastType !== itemType) {
|
||||
title = t('Nearby Payees');
|
||||
}
|
||||
const showMoreMessage =
|
||||
idx === items.length - 1 && items.length > 100;
|
||||
@@ -296,6 +312,7 @@ export function PayeeAutocomplete({
|
||||
if (!payees) {
|
||||
payees = retrievedPayees;
|
||||
}
|
||||
const { payees: nearbyPayees } = useNearbyPayees();
|
||||
|
||||
const cachedAccounts = useAccounts();
|
||||
if (!accounts) {
|
||||
@@ -306,7 +323,7 @@ export function PayeeAutocomplete({
|
||||
const [rawPayee, setRawPayee] = useState('');
|
||||
const hasPayeeInput = !!rawPayee;
|
||||
const payeeSuggestions: PayeeAutocompleteItem[] = useMemo(() => {
|
||||
const suggestions = getPayeeSuggestions(commonPayees, payees);
|
||||
const suggestions = getPayeeSuggestions(nearbyPayees, commonPayees, payees);
|
||||
const filteredSuggestions = filterActivePayees(
|
||||
suggestions,
|
||||
focusTransferPayees,
|
||||
@@ -318,7 +335,14 @@ export function PayeeAutocomplete({
|
||||
}
|
||||
|
||||
return [{ id: 'new', favorite: false, name: '' }, ...filteredSuggestions];
|
||||
}, [commonPayees, payees, focusTransferPayees, accounts, hasPayeeInput]);
|
||||
}, [
|
||||
nearbyPayees,
|
||||
commonPayees,
|
||||
payees,
|
||||
focusTransferPayees,
|
||||
accounts,
|
||||
hasPayeeInput,
|
||||
]);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
|
||||
import { useInitialMount } from '@desktop-client/hooks/useInitialMount';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { useNearbyPayees } from '@desktop-client/hooks/useNearbyPayees';
|
||||
import { usePayees } from '@desktop-client/hooks/usePayees';
|
||||
import {
|
||||
SingleActiveEditFormProvider,
|
||||
@@ -1223,6 +1224,8 @@ function TransactionEditUnconnected({
|
||||
[dateFormat, transactions],
|
||||
);
|
||||
|
||||
const { assignPayeesToGeolocation: assignPayeesToLocation } = useNearbyPayees();
|
||||
|
||||
const onSave = useCallback(
|
||||
async newTransactions => {
|
||||
if (isDeleted.current) {
|
||||
@@ -1254,8 +1257,10 @@ function TransactionEditUnconnected({
|
||||
// about
|
||||
dispatch(setLastTransaction({ transaction: newTransactions[0] }));
|
||||
}
|
||||
|
||||
await assignPayeesToLocation(newTransactions.map(t => t.payee));
|
||||
},
|
||||
[dispatch, fetchedTransactions],
|
||||
[assignPayeesToLocation, dispatch, fetchedTransactions],
|
||||
);
|
||||
|
||||
const onDelete = useCallback(
|
||||
|
||||
42
packages/desktop-client/src/hooks/useGeolocation.ts
Normal file
42
packages/desktop-client/src/hooks/useGeolocation.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useGeolocation() {
|
||||
const [coordinates, setCoordinates] = useState<GeolocationCoordinates | null>(
|
||||
null,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isUnmounted = false;
|
||||
|
||||
if ('geolocation' in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async position => {
|
||||
if (!isUnmounted) {
|
||||
setCoordinates(position.coords);
|
||||
}
|
||||
},
|
||||
error => {
|
||||
if (!isUnmounted) {
|
||||
console.log(
|
||||
`Error occurred while getting geolocation: ${error.message}`,
|
||||
);
|
||||
setError(error.message);
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
if (!isUnmounted) {
|
||||
setError('Geolocation is not supported by this browser.');
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
isUnmounted = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
coordinates,
|
||||
error,
|
||||
};
|
||||
}
|
||||
124
packages/desktop-client/src/hooks/useNearbyPayees.ts
Normal file
124
packages/desktop-client/src/hooks/useNearbyPayees.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { type PayeeEntity } from 'loot-core/types/models';
|
||||
|
||||
import { useGeolocation } from './useGeolocation';
|
||||
import { usePayees } from './usePayees';
|
||||
import { usePayeeGeolocations } from './usePayeeGeolocations';
|
||||
import { assignPayeeGeolocation } from '@desktop-client/queries/queriesSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
type LatLongCoordinates = Pick<
|
||||
GeolocationCoordinates,
|
||||
'latitude' | 'longitude'
|
||||
>;
|
||||
|
||||
export function useNearbyPayees({ thresholdInMeters = 50 } = {}) {
|
||||
const dispatch = useDispatch();
|
||||
const { coordinates: currentCoordinates, error } = useGeolocation();
|
||||
const payees = usePayees();
|
||||
const payeeGeolocations = usePayeeGeolocations();
|
||||
|
||||
console.log('payeeGeolocations', payeeGeolocations);
|
||||
|
||||
const payeesWithGeocoordinates = useMemo(
|
||||
() =>
|
||||
payees.reduce(
|
||||
(acc, payee) => {
|
||||
const payeeCoordinatesList = payeeGeolocations.filter(pg => pg.payee_id === payee.id);
|
||||
for (const payeeCoordinates of payeeCoordinatesList) {
|
||||
acc.push({
|
||||
...payee,
|
||||
coordinates: payeeCoordinates,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as (PayeeEntity & { coordinates: LatLongCoordinates })[],
|
||||
),
|
||||
[payees, payeeGeolocations],
|
||||
);
|
||||
|
||||
const getPayeesWithinThreshold = useCallback(
|
||||
(coordinates: LatLongCoordinates | null, thresholdInMeters: number) =>
|
||||
payeesWithGeocoordinates
|
||||
.map(payee => ({
|
||||
...payee,
|
||||
distance: getDistance(coordinates, payee.coordinates),
|
||||
}))
|
||||
.filter(payee => payee.distance >= 0)
|
||||
.filter(payee => payee.distance <= thresholdInMeters)
|
||||
.sort((a, b) => a.distance - b.distance),
|
||||
[payeesWithGeocoordinates],
|
||||
);
|
||||
|
||||
const payeesWithinThreshold = useMemo(
|
||||
() => getPayeesWithinThreshold(currentCoordinates, thresholdInMeters),
|
||||
[currentCoordinates, getPayeesWithinThreshold, thresholdInMeters],
|
||||
);
|
||||
|
||||
const assignPayeesToGeolocation = useCallback(
|
||||
async (
|
||||
payeeIds: Array<PayeeEntity['id']>,
|
||||
coordinates: LatLongCoordinates | null = currentCoordinates,
|
||||
) => {
|
||||
if (!coordinates) {
|
||||
console.warn('Location is not available.');
|
||||
return;
|
||||
}
|
||||
|
||||
const payeesWithinThresholdSet = new Set(
|
||||
payeesWithinThreshold.map(p => p.id),
|
||||
);
|
||||
|
||||
for (const payeeId of payeeIds) {
|
||||
// If current coordinates is within the threshold of any
|
||||
// existing payee coordinates, skip
|
||||
if (payeesWithinThresholdSet.has(payeeId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
dispatch(assignPayeeGeolocation({
|
||||
payeeId,
|
||||
latitude: coordinates.latitude,
|
||||
longitude: coordinates.longitude,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[currentCoordinates, payeesWithinThreshold],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
payees: payeesWithinThreshold,
|
||||
coordinates: currentCoordinates,
|
||||
assignPayeesToGeolocation,
|
||||
error,
|
||||
}),
|
||||
[payeesWithinThreshold, currentCoordinates, assignPayeesToGeolocation, error],
|
||||
);
|
||||
}
|
||||
|
||||
function getDistance(
|
||||
currentLatLong: LatLongCoordinates | null,
|
||||
referenceLatLong: LatLongCoordinates | null,
|
||||
) {
|
||||
if (!currentLatLong || !referenceLatLong) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const R = 6371000; // Earth's radius in meters
|
||||
const deltaLat =
|
||||
((currentLatLong.latitude - referenceLatLong.latitude) * Math.PI) / 180;
|
||||
const deltaLong =
|
||||
((currentLatLong.longitude - referenceLatLong.longitude) * Math.PI) / 180;
|
||||
|
||||
const a =
|
||||
Math.sin(deltaLat / 2) ** 2 +
|
||||
Math.cos((referenceLatLong.latitude * Math.PI) / 180) *
|
||||
Math.cos((currentLatLong.latitude * Math.PI) / 180) *
|
||||
Math.sin(deltaLong / 2) ** 2;
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
23
packages/desktop-client/src/hooks/usePayeeGeolocations.ts
Normal file
23
packages/desktop-client/src/hooks/usePayeeGeolocations.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useInitialMount } from './useInitialMount';
|
||||
|
||||
import {
|
||||
getPayeeGeolocations,
|
||||
} from '@desktop-client/queries/queriesSlice';
|
||||
import { useSelector, useDispatch } from '@desktop-client/redux';
|
||||
|
||||
export function usePayeeGeolocations() {
|
||||
const dispatch = useDispatch();
|
||||
const payeeGeolocationsLoaded = useSelector(state => state.queries.payeeGeolocationsLoaded);
|
||||
|
||||
const isInitialMount = useInitialMount();
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialMount && !payeeGeolocationsLoaded) {
|
||||
dispatch(getPayeeGeolocations());
|
||||
}
|
||||
}, [dispatch, isInitialMount, payeeGeolocationsLoaded]);
|
||||
|
||||
return useSelector(state => state.queries.payeeGeolocations);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type AccountEntity,
|
||||
type PayeeEntity,
|
||||
type Tag,
|
||||
type PayeeGeolocationEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { resetApp } from '@desktop-client/app/appSlice';
|
||||
@@ -43,6 +44,8 @@ type QueriesState = {
|
||||
payeesLoaded: boolean;
|
||||
tags: Tag[];
|
||||
tagsLoaded: boolean;
|
||||
payeeGeolocations: PayeeGeolocationEntity[];
|
||||
payeeGeolocationsLoaded: boolean;
|
||||
};
|
||||
|
||||
const initialState: QueriesState = {
|
||||
@@ -63,6 +66,8 @@ const initialState: QueriesState = {
|
||||
payeesLoaded: false,
|
||||
tags: [],
|
||||
tagsLoaded: false,
|
||||
payeeGeolocations: [],
|
||||
payeeGeolocationsLoaded: false,
|
||||
};
|
||||
|
||||
type SetNewTransactionsPayload = {
|
||||
@@ -193,6 +198,31 @@ const queriesSlice = createSlice({
|
||||
builder.addCase(findTags.fulfilled, (state, action) => {
|
||||
state.tags = action.payload;
|
||||
});
|
||||
|
||||
// Payee geolocations
|
||||
builder.addCase(getPayeeGeolocations.fulfilled, (state, action) => {
|
||||
state.payeeGeolocations = action.payload;
|
||||
state.payeeGeolocationsLoaded = true;
|
||||
});
|
||||
|
||||
builder.addCase(assignPayeeGeolocation.fulfilled, (state, action) => {
|
||||
const index = state.payeeGeolocations.findIndex(
|
||||
p => p.payee_id === action.meta.arg.payeeId,
|
||||
);
|
||||
if (index !== -1) {
|
||||
state.payeeGeolocations[index] = {
|
||||
payee_id: action.meta.arg.payeeId,
|
||||
latitude: action.meta.arg.latitude,
|
||||
longitude: action.meta.arg.longitude,
|
||||
};
|
||||
} else {
|
||||
state.payeeGeolocations.push({
|
||||
payee_id: action.meta.arg.payeeId,
|
||||
latitude: action.meta.arg.latitude,
|
||||
longitude: action.meta.arg.longitude,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -452,6 +482,28 @@ export const getPayees = createAppAsyncThunk(
|
||||
},
|
||||
);
|
||||
|
||||
export const getPayeeGeolocations = createAppAsyncThunk(
|
||||
`${sliceName}/getPayeeGeolocations`,
|
||||
async () => {
|
||||
const payeeGeolocations: PayeeGeolocationEntity[] = await send('payees-geolocation-get');
|
||||
return payeeGeolocations;
|
||||
},
|
||||
);
|
||||
|
||||
type AssignPayeeGeolocationPayload = {
|
||||
payeeId: PayeeEntity['id'];
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export const assignPayeeGeolocation = createAppAsyncThunk(
|
||||
`${sliceName}/assignPayeeGeolocation`,
|
||||
async ({ payeeId, latitude, longitude }: AssignPayeeGeolocationPayload) => {
|
||||
const geolocationId = await send('payees-geolocation-assign', { payeeId, latitude, longitude });
|
||||
return geolocationId;
|
||||
},
|
||||
);
|
||||
|
||||
export const getTags = createAppAsyncThunk(`${sliceName}/getTags`, async () => {
|
||||
const tags: Tag[] = await send('tags-get');
|
||||
return tags;
|
||||
@@ -961,6 +1013,8 @@ export const actions = {
|
||||
moveCategoryGroup,
|
||||
initiallyLoadPayees,
|
||||
getTags,
|
||||
getPayeeGeolocations,
|
||||
assignPayeeGeolocation,
|
||||
};
|
||||
|
||||
export const {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE TABLE payee_geolocations(
|
||||
id TEXT PRIMARY KEY,
|
||||
payee_id TEXT,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
FOREIGN KEY (payee_id) REFERENCES payees(id)
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
DbCategoryMapping,
|
||||
DbClockMessage,
|
||||
DbPayee,
|
||||
DbPayeeGeolocation,
|
||||
DbPayeeMapping,
|
||||
DbTag,
|
||||
DbTransaction,
|
||||
@@ -585,6 +586,19 @@ export function updatePayee(payee: WithRequired<Partial<DbPayee>, 'id'>) {
|
||||
return update('payees', payee);
|
||||
}
|
||||
|
||||
export async function getPayeeGeolocations(): Promise<DbPayeeGeolocation[]> {
|
||||
return all<DbPayeeGeolocation>(
|
||||
'SELECT * FROM payee_geolocations',
|
||||
);
|
||||
}
|
||||
|
||||
export async function insertPayeeGeolocation(
|
||||
payeeGeolocation: WithRequired<Partial<DbPayeeGeolocation>, 'payee_id' | 'latitude' | 'longitude'>,
|
||||
) {
|
||||
const geolocationId = await insertWithUUID('payee_geolocations', payeeGeolocation);
|
||||
return geolocationId;
|
||||
}
|
||||
|
||||
export async function mergePayees(
|
||||
target: DbPayee['id'],
|
||||
ids: Array<DbPayee['id']>,
|
||||
|
||||
@@ -332,3 +332,10 @@ export type DbTag = {
|
||||
description?: string | null;
|
||||
tombstone: 1 | 0;
|
||||
};
|
||||
|
||||
export type DbPayeeGeolocation = {
|
||||
id: string;
|
||||
payee_id: DbPayee['id'];
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Diff } from '../../shared/util';
|
||||
import { PayeeEntity, RuleEntity } from '../../types/models';
|
||||
import { PayeeEntity, PayeeGeolocationEntity, RuleEntity } from '../../types/models';
|
||||
import { createApp } from '../app';
|
||||
import * as db from '../db';
|
||||
import { payeeModel } from '../models';
|
||||
@@ -18,6 +18,8 @@ export type PayeesHandlers = {
|
||||
'payees-batch-change': typeof batchChangePayees;
|
||||
'payees-check-orphaned': typeof checkOrphanedPayees;
|
||||
'payees-get-rules': typeof getPayeeRules;
|
||||
'payees-geolocation-get': typeof getPayeeGeolocations;
|
||||
'payees-geolocation-assign': typeof assignPayeeGeolocation;
|
||||
};
|
||||
|
||||
export const app = createApp<PayeesHandlers>();
|
||||
@@ -38,6 +40,8 @@ app.method(
|
||||
app.method('payees-batch-change', mutator(undoable(batchChangePayees)));
|
||||
app.method('payees-check-orphaned', checkOrphanedPayees);
|
||||
app.method('payees-get-rules', getPayeeRules);
|
||||
app.method('payees-geolocation-assign', mutator(undoable(assignPayeeGeolocation)));
|
||||
app.method('payees-geolocation-get', getPayeeGeolocations);
|
||||
|
||||
async function createPayee({ name }: { name: PayeeEntity['name'] }) {
|
||||
return db.insertPayee({ name });
|
||||
@@ -124,3 +128,19 @@ async function getPayeeRules({
|
||||
}): Promise<RuleEntity[]> {
|
||||
return rules.getRulesForPayee(id).map(rule => rule.serialize());
|
||||
}
|
||||
|
||||
async function getPayeeGeolocations(): Promise<PayeeGeolocationEntity[]> {
|
||||
return db.getPayeeGeolocations();
|
||||
}
|
||||
|
||||
async function assignPayeeGeolocation({
|
||||
payeeId,
|
||||
latitude,
|
||||
longitude,
|
||||
}: {
|
||||
payeeId: PayeeEntity['id'];
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}): Promise<void> {
|
||||
await db.insertPayeeGeolocation({ payee_id: payeeId, latitude, longitude });
|
||||
}
|
||||
@@ -19,3 +19,4 @@ export type * from './transaction-filter';
|
||||
export type * from './user';
|
||||
export type * from './user-access';
|
||||
export type * from './tags';
|
||||
export type * from './payee-geolocation';
|
||||
|
||||
5
packages/loot-core/src/types/models/payee-geolocation.ts
Normal file
5
packages/loot-core/src/types/models/payee-geolocation.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface PayeeGeolocationEntity {
|
||||
payee_id: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
6
upcoming-release-notes/4945.md
Normal file
6
upcoming-release-notes/4945.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
[Mobile] Suggest nearby payees when entering transactions
|
||||
Reference in New Issue
Block a user