mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Remember last used channel when selected
This commit is contained in:
@@ -19,6 +19,11 @@ import { Separator } from './ui/separator';
|
||||
import { toast } from './ui/sonner';
|
||||
import { api } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import {
|
||||
captureLastViewedConversationFromHash,
|
||||
getReopenLastConversationEnabled,
|
||||
setReopenLastConversationEnabled,
|
||||
} from '../utils/lastViewedConversation';
|
||||
|
||||
// Radio presets for common configurations
|
||||
interface RadioPreset {
|
||||
@@ -141,6 +146,9 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
const [retentionDays, setRetentionDays] = useState('14');
|
||||
const [cleaning, setCleaning] = useState(false);
|
||||
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
|
||||
const [reopenLastConversation, setReopenLastConversation] = useState(
|
||||
getReopenLastConversationEnabled
|
||||
);
|
||||
|
||||
// Advertisement interval state
|
||||
const [advertInterval, setAdvertInterval] = useState('0');
|
||||
@@ -222,6 +230,12 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
}
|
||||
}, [open, pageMode, onRefreshAppSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open || pageMode) {
|
||||
setReopenLastConversation(getReopenLastConversationEnabled());
|
||||
}
|
||||
}, [open, pageMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
||||
|
||||
@@ -529,6 +543,14 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleReopenLastConversation = (enabled: boolean) => {
|
||||
setReopenLastConversation(enabled);
|
||||
setReopenLastConversationEnabled(enabled);
|
||||
if (enabled) {
|
||||
captureLastViewedConversationFromHash();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveBotSettings = async () => {
|
||||
setBusySection('bot');
|
||||
setSectionError(null);
|
||||
@@ -1044,6 +1066,24 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Interface</Label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reopenLastConversation}
|
||||
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Reopen to last viewed channel/conversation</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This applies only to this device/browser. It does not sync to server settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{getSectionError('database') && (
|
||||
<div className="text-sm text-destructive">{getSectionError('database')}</div>
|
||||
)}
|
||||
|
||||
@@ -19,7 +19,7 @@ export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
|
||||
radio: '📻 Radio',
|
||||
identity: '🪪 Identity',
|
||||
connectivity: '📡 Connectivity',
|
||||
database: '🗄️ Database',
|
||||
database: '🗄️ Database & Interfacr',
|
||||
bot: '🤖 Bot',
|
||||
statistics: '📊 Statistics',
|
||||
};
|
||||
|
||||
@@ -5,6 +5,11 @@ import {
|
||||
resolveChannelFromHashToken,
|
||||
resolveContactFromHashToken,
|
||||
} from '../utils/urlHash';
|
||||
import {
|
||||
getLastViewedConversation,
|
||||
getReopenLastConversationEnabled,
|
||||
saveLastViewedConversation,
|
||||
} from '../utils/lastViewedConversation';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import type { Channel, Contact, Conversation } from '../types';
|
||||
|
||||
@@ -30,6 +35,16 @@ export function useConversationRouter({
|
||||
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
|
||||
const activeConversationRef = useRef<Conversation | null>(null);
|
||||
|
||||
const getPublicChannelConversation = useCallback((): Conversation | null => {
|
||||
const publicChannel = channels.find((c) => c.name === 'Public');
|
||||
if (!publicChannel) return null;
|
||||
return {
|
||||
type: 'channel',
|
||||
id: publicChannel.key,
|
||||
name: publicChannel.name,
|
||||
};
|
||||
}, [channels]);
|
||||
|
||||
// Phase 1: Set initial conversation from URL hash or default to Public channel
|
||||
// Only needs channels (fast path) - doesn't wait for contacts
|
||||
useEffect(() => {
|
||||
@@ -73,17 +88,49 @@ export function useConversationRouter({
|
||||
// Contact hash — wait for phase 2
|
||||
if (hashConv?.type === 'contact') return;
|
||||
|
||||
// No hash: optionally restore last-viewed conversation if enabled on this device.
|
||||
if (!hashConv && getReopenLastConversationEnabled()) {
|
||||
const lastViewed = getLastViewedConversation();
|
||||
if (lastViewed?.type === 'raw') {
|
||||
setActiveConversation(lastViewed);
|
||||
hasSetDefaultConversation.current = true;
|
||||
return;
|
||||
}
|
||||
if (lastViewed?.type === 'map') {
|
||||
setActiveConversation(lastViewed);
|
||||
hasSetDefaultConversation.current = true;
|
||||
return;
|
||||
}
|
||||
if (lastViewed?.type === 'visualizer') {
|
||||
setActiveConversation(lastViewed);
|
||||
hasSetDefaultConversation.current = true;
|
||||
return;
|
||||
}
|
||||
if (lastViewed?.type === 'channel') {
|
||||
const channel =
|
||||
channels.find((c) => c.key.toLowerCase() === lastViewed.id.toLowerCase()) ||
|
||||
resolveChannelFromHashToken(lastViewed.id, channels);
|
||||
if (channel) {
|
||||
setActiveConversation({
|
||||
type: 'channel',
|
||||
id: channel.key,
|
||||
name: channel.name,
|
||||
});
|
||||
hasSetDefaultConversation.current = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Last-viewed contact resolution waits for contacts in phase 2.
|
||||
if (lastViewed?.type === 'contact') return;
|
||||
}
|
||||
|
||||
// No hash or unresolvable — default to Public
|
||||
const publicChannel = channels.find((c) => c.name === 'Public');
|
||||
if (publicChannel) {
|
||||
setActiveConversation({
|
||||
type: 'channel',
|
||||
id: publicChannel.key,
|
||||
name: publicChannel.name,
|
||||
});
|
||||
const publicConversation = getPublicChannelConversation();
|
||||
if (publicConversation) {
|
||||
setActiveConversation(publicConversation);
|
||||
hasSetDefaultConversation.current = true;
|
||||
}
|
||||
}, [channels, activeConversation]);
|
||||
}, [channels, activeConversation, getPublicChannelConversation]);
|
||||
|
||||
// Phase 2: Resolve contact hash (only if phase 1 didn't set a conversation)
|
||||
useEffect(() => {
|
||||
@@ -105,25 +152,49 @@ export function useConversationRouter({
|
||||
}
|
||||
|
||||
// Contact hash didn't match — fall back to Public if channels loaded.
|
||||
if (channels.length > 0) {
|
||||
const publicChannel = channels.find((c) => c.name === 'Public');
|
||||
if (publicChannel) {
|
||||
setActiveConversation({
|
||||
type: 'channel',
|
||||
id: publicChannel.key,
|
||||
name: publicChannel.name,
|
||||
});
|
||||
hasSetDefaultConversation.current = true;
|
||||
}
|
||||
const publicConversation = getPublicChannelConversation();
|
||||
if (publicConversation) {
|
||||
setActiveConversation(publicConversation);
|
||||
hasSetDefaultConversation.current = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No hash: optionally restore a last-viewed contact once contacts are loaded.
|
||||
if (!hashConv && getReopenLastConversationEnabled()) {
|
||||
const lastViewed = getLastViewedConversation();
|
||||
if (lastViewed?.type !== 'contact') return;
|
||||
if (!contactsLoaded) return;
|
||||
|
||||
const contact = contacts.find(
|
||||
(item) => item.public_key.toLowerCase() === lastViewed.id.toLowerCase()
|
||||
);
|
||||
if (contact) {
|
||||
setActiveConversation({
|
||||
type: 'contact',
|
||||
id: contact.public_key,
|
||||
name: getContactDisplayName(contact.name, contact.public_key),
|
||||
});
|
||||
hasSetDefaultConversation.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const publicConversation = getPublicChannelConversation();
|
||||
if (publicConversation) {
|
||||
setActiveConversation(publicConversation);
|
||||
hasSetDefaultConversation.current = true;
|
||||
}
|
||||
}
|
||||
}, [contacts, channels, activeConversation, contactsLoaded]);
|
||||
}, [contacts, channels, activeConversation, contactsLoaded, getPublicChannelConversation]);
|
||||
|
||||
// Keep ref in sync and update URL hash
|
||||
useEffect(() => {
|
||||
activeConversationRef.current = activeConversation;
|
||||
if (activeConversation) {
|
||||
updateUrlHash(activeConversation);
|
||||
if (getReopenLastConversationEnabled()) {
|
||||
saveLastViewedConversation(activeConversation);
|
||||
}
|
||||
}
|
||||
}, [activeConversation]);
|
||||
|
||||
|
||||
@@ -137,6 +137,10 @@ vi.mock('../components/ui/sonner', () => ({
|
||||
}));
|
||||
|
||||
import { App } from '../App';
|
||||
import {
|
||||
LAST_VIEWED_CONVERSATION_KEY,
|
||||
REOPEN_LAST_CONVERSATION_KEY,
|
||||
} from '../utils/lastViewedConversation';
|
||||
|
||||
const publicChannel = {
|
||||
key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72',
|
||||
@@ -149,6 +153,7 @@ const publicChannel = {
|
||||
describe('App startup hash resolution', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
window.location.hash = `#contact/${'a'.repeat(64)}/Alice`;
|
||||
|
||||
mocks.api.getRadioConfig.mockResolvedValue({
|
||||
@@ -178,6 +183,7 @@ describe('App startup hash resolution', () => {
|
||||
|
||||
afterEach(() => {
|
||||
window.location.hash = '';
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('falls back to Public when contact hash is unresolvable and contacts are empty', async () => {
|
||||
@@ -189,4 +195,63 @@ describe('App startup hash resolution', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('restores last viewed channel when hash is empty and reopen preference is enabled', async () => {
|
||||
const chatChannel = {
|
||||
key: '11111111111111111111111111111111',
|
||||
name: 'Ops',
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
};
|
||||
|
||||
window.location.hash = '';
|
||||
localStorage.setItem(REOPEN_LAST_CONVERSATION_KEY, '1');
|
||||
localStorage.setItem(
|
||||
LAST_VIEWED_CONVERSATION_KEY,
|
||||
JSON.stringify({
|
||||
type: 'channel',
|
||||
id: chatChannel.key,
|
||||
name: chatChannel.name,
|
||||
})
|
||||
);
|
||||
mocks.api.getChannels.mockResolvedValue([publicChannel, chatChannel]);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
for (const node of screen.getAllByTestId('active-conversation')) {
|
||||
expect(node).toHaveTextContent(`channel:${chatChannel.key}:${chatChannel.name}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('uses Public channel when hash is empty and reopen preference is disabled', async () => {
|
||||
const chatChannel = {
|
||||
key: '11111111111111111111111111111111',
|
||||
name: 'Ops',
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
};
|
||||
|
||||
window.location.hash = '';
|
||||
localStorage.setItem(
|
||||
LAST_VIEWED_CONVERSATION_KEY,
|
||||
JSON.stringify({
|
||||
type: 'channel',
|
||||
id: chatChannel.key,
|
||||
name: chatChannel.name,
|
||||
})
|
||||
);
|
||||
mocks.api.getChannels.mockResolvedValue([publicChannel, chatChannel]);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
for (const node of screen.getAllByTestId('active-conversation')) {
|
||||
expect(node).toHaveTextContent(`channel:${publicChannel.key}:Public`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,10 @@ import type {
|
||||
StatisticsResponse,
|
||||
} from '../types';
|
||||
import type { SettingsSection } from '../components/SettingsModal';
|
||||
import {
|
||||
LAST_VIEWED_CONVERSATION_KEY,
|
||||
REOPEN_LAST_CONVERSATION_KEY,
|
||||
} from '../utils/lastViewedConversation';
|
||||
|
||||
const baseConfig: RadioConfig = {
|
||||
public_key: 'aa'.repeat(32),
|
||||
@@ -128,9 +132,16 @@ function openConnectivitySection() {
|
||||
fireEvent.click(connectivityToggle);
|
||||
}
|
||||
|
||||
function openDatabaseSection() {
|
||||
const databaseToggle = screen.getByRole('button', { name: /Database/i });
|
||||
fireEvent.click(databaseToggle);
|
||||
}
|
||||
|
||||
describe('SettingsModal', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
window.location.hash = '';
|
||||
});
|
||||
|
||||
it('refreshes app settings when opened', async () => {
|
||||
@@ -291,6 +302,25 @@ describe('SettingsModal', () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stores and clears reopen-last-conversation preference locally', () => {
|
||||
window.location.hash = '#raw';
|
||||
renderModal();
|
||||
openDatabaseSection();
|
||||
|
||||
const checkbox = screen.getByLabelText('Reopen to last viewed channel/conversation');
|
||||
expect(checkbox).not.toBeChecked();
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(localStorage.getItem(REOPEN_LAST_CONVERSATION_KEY)).toBe('1');
|
||||
expect(localStorage.getItem(LAST_VIEWED_CONVERSATION_KEY)).toContain('"type":"raw"');
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(localStorage.getItem(REOPEN_LAST_CONVERSATION_KEY)).toBeNull();
|
||||
expect(localStorage.getItem(LAST_VIEWED_CONVERSATION_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('renders statistics section with fetched data', async () => {
|
||||
const mockStats: StatisticsResponse = {
|
||||
busiest_channels_24h: [
|
||||
|
||||
103
frontend/src/utils/lastViewedConversation.ts
Normal file
103
frontend/src/utils/lastViewedConversation.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { Conversation } from '../types';
|
||||
import { parseHashConversation } from './urlHash';
|
||||
|
||||
export const REOPEN_LAST_CONVERSATION_KEY = 'remoteterm-reopen-last-conversation';
|
||||
export const LAST_VIEWED_CONVERSATION_KEY = 'remoteterm-last-viewed-conversation';
|
||||
|
||||
const SUPPORTED_TYPES: Conversation['type'][] = ['contact', 'channel', 'raw', 'map', 'visualizer'];
|
||||
|
||||
function isSupportedType(value: unknown): value is Conversation['type'] {
|
||||
return typeof value === 'string' && SUPPORTED_TYPES.includes(value as Conversation['type']);
|
||||
}
|
||||
|
||||
export function getReopenLastConversationEnabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(REOPEN_LAST_CONVERSATION_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setReopenLastConversationEnabled(enabled: boolean): void {
|
||||
try {
|
||||
if (enabled) {
|
||||
localStorage.setItem(REOPEN_LAST_CONVERSATION_KEY, '1');
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(REOPEN_LAST_CONVERSATION_KEY);
|
||||
localStorage.removeItem(LAST_VIEWED_CONVERSATION_KEY);
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
}
|
||||
|
||||
export function saveLastViewedConversation(conversation: Conversation): void {
|
||||
try {
|
||||
localStorage.setItem(LAST_VIEWED_CONVERSATION_KEY, JSON.stringify(conversation));
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
}
|
||||
|
||||
export function getLastViewedConversation(): Conversation | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(LAST_VIEWED_CONVERSATION_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<Conversation>;
|
||||
if (
|
||||
!isSupportedType(parsed.type) ||
|
||||
typeof parsed.id !== 'string' ||
|
||||
typeof parsed.name !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parsed.type !== 'map') {
|
||||
return {
|
||||
type: parsed.type,
|
||||
id: parsed.id,
|
||||
name: parsed.name,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'map',
|
||||
id: parsed.id,
|
||||
name: parsed.name,
|
||||
...(typeof parsed.mapFocusKey === 'string' && { mapFocusKey: parsed.mapFocusKey }),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function captureLastViewedConversationFromHash(): void {
|
||||
const hashConversation = parseHashConversation();
|
||||
if (!hashConversation) return;
|
||||
|
||||
if (hashConversation.type === 'raw') {
|
||||
saveLastViewedConversation({ type: 'raw', id: 'raw', name: 'Raw Packet Feed' });
|
||||
return;
|
||||
}
|
||||
if (hashConversation.type === 'map') {
|
||||
saveLastViewedConversation({
|
||||
type: 'map',
|
||||
id: 'map',
|
||||
name: 'Node Map',
|
||||
...(hashConversation.mapFocusKey && { mapFocusKey: hashConversation.mapFocusKey }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (hashConversation.type === 'visualizer') {
|
||||
saveLastViewedConversation({ type: 'visualizer', id: 'visualizer', name: 'Mesh Visualizer' });
|
||||
return;
|
||||
}
|
||||
|
||||
saveLastViewedConversation({
|
||||
type: hashConversation.type,
|
||||
id: hashConversation.name,
|
||||
name: hashConversation.label || hashConversation.name,
|
||||
});
|
||||
}
|
||||
80
tests/e2e/specs/reopen-last-conversation.spec.ts
Normal file
80
tests/e2e/specs/reopen-last-conversation.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { createChannel, deleteChannel } from '../helpers/api';
|
||||
|
||||
const REOPEN_LAST_CONVERSATION_KEY = 'remoteterm-reopen-last-conversation';
|
||||
const LAST_VIEWED_CONVERSATION_KEY = 'remoteterm-last-viewed-conversation';
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
test.describe('Reopen last conversation (device-local)', () => {
|
||||
let channelName = '';
|
||||
let channelKey = '';
|
||||
|
||||
test.beforeAll(async () => {
|
||||
channelName = `#e2ereopen${Date.now().toString().slice(-6)}`;
|
||||
const channel = await createChannel(channelName);
|
||||
channelKey = channel.key;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
await deleteChannel(channelKey);
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
});
|
||||
|
||||
test('reopens last viewed conversation on startup when enabled', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Connected')).toBeVisible();
|
||||
|
||||
await page.getByText(channelName, { exact: true }).first().click();
|
||||
await expect(
|
||||
page.getByPlaceholder(new RegExp(`message\\s+${escapeRegex(channelName)}`, 'i'))
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Settings' }).click();
|
||||
await page.getByRole('button', { name: /Database & Interfacr/i }).click();
|
||||
await page.getByLabel('Reopen to last viewed channel/conversation').check();
|
||||
await page.getByRole('button', { name: 'Back to Chat' }).click();
|
||||
|
||||
// Fresh launch path without hash should restore the saved conversation.
|
||||
await page.goto('/');
|
||||
await expect(
|
||||
page.getByPlaceholder(new RegExp(`message\\s+${escapeRegex(channelName)}`, 'i'))
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('clears local storage and falls back to default when disabled', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Connected')).toBeVisible();
|
||||
|
||||
await page.getByText(channelName, { exact: true }).first().click();
|
||||
await expect(
|
||||
page.getByPlaceholder(new RegExp(`message\\s+${escapeRegex(channelName)}`, 'i'))
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Settings' }).click();
|
||||
await page.getByRole('button', { name: /Database & Interfacr/i }).click();
|
||||
|
||||
const reopenToggle = page.getByLabel('Reopen to last viewed channel/conversation');
|
||||
await reopenToggle.check();
|
||||
await reopenToggle.uncheck();
|
||||
|
||||
const localState = await page.evaluate(
|
||||
([enabledKey, lastViewedKey]) => ({
|
||||
enabled: localStorage.getItem(enabledKey),
|
||||
lastViewed: localStorage.getItem(lastViewedKey),
|
||||
}),
|
||||
[REOPEN_LAST_CONVERSATION_KEY, LAST_VIEWED_CONVERSATION_KEY]
|
||||
);
|
||||
expect(localState.enabled).toBeNull();
|
||||
expect(localState.lastViewed).toBeNull();
|
||||
|
||||
await page.getByRole('button', { name: 'Back to Chat' }).click();
|
||||
await page.goto('/');
|
||||
await expect(page.getByPlaceholder(/message\s+Public/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user