Remember last used channel when selected

This commit is contained in:
Jack Kingsman
2026-02-20 17:03:13 -08:00
parent 41bf4eb73a
commit f9eb46f2ab
7 changed files with 409 additions and 20 deletions

View File

@@ -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>
)}

View File

@@ -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',
};

View File

@@ -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]);

View File

@@ -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`);
}
});
});
});

View File

@@ -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: [

View 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,
});
}

View 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();
});
});