From f9eb46f2ab3d1a7f3974514a92d60777202da32f Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Fri, 20 Feb 2026 17:03:13 -0800
Subject: [PATCH] Remember last used channel when selected
---
frontend/src/components/SettingsModal.tsx | 40 +++++++
frontend/src/components/settingsConstants.ts | 2 +-
frontend/src/hooks/useConversationRouter.ts | 109 +++++++++++++++---
frontend/src/test/appStartupHash.test.tsx | 65 +++++++++++
frontend/src/test/settingsModal.test.tsx | 30 +++++
frontend/src/utils/lastViewedConversation.ts | 103 +++++++++++++++++
.../specs/reopen-last-conversation.spec.ts | 80 +++++++++++++
7 files changed, 409 insertions(+), 20 deletions(-)
create mode 100644 frontend/src/utils/lastViewedConversation.ts
create mode 100644 tests/e2e/specs/reopen-last-conversation.spec.ts
diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx
index e25f707..792bdd4 100644
--- a/frontend/src/components/SettingsModal.tsx
+++ b/frontend/src/components/SettingsModal.tsx
@@ -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) {
+
+
+
+
+
+
+ This applies only to this device/browser. It does not sync to server settings.
+
+
+
{getSectionError('database') && (
{getSectionError('database')}
)}
diff --git a/frontend/src/components/settingsConstants.ts b/frontend/src/components/settingsConstants.ts
index 22931b8..d28475f 100644
--- a/frontend/src/components/settingsConstants.ts
+++ b/frontend/src/components/settingsConstants.ts
@@ -19,7 +19,7 @@ export const SETTINGS_SECTION_LABELS: Record = {
radio: '📻 Radio',
identity: '🪪 Identity',
connectivity: '📡 Connectivity',
- database: '🗄️ Database',
+ database: '🗄️ Database & Interfacr',
bot: '🤖 Bot',
statistics: '📊 Statistics',
};
diff --git a/frontend/src/hooks/useConversationRouter.ts b/frontend/src/hooks/useConversationRouter.ts
index 0b67223..de60314 100644
--- a/frontend/src/hooks/useConversationRouter.ts
+++ b/frontend/src/hooks/useConversationRouter.ts
@@ -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(null);
const activeConversationRef = useRef(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]);
diff --git a/frontend/src/test/appStartupHash.test.tsx b/frontend/src/test/appStartupHash.test.tsx
index 8752e54..5cb1e93 100644
--- a/frontend/src/test/appStartupHash.test.tsx
+++ b/frontend/src/test/appStartupHash.test.tsx
@@ -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();
+
+ 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();
+
+ await waitFor(() => {
+ for (const node of screen.getAllByTestId('active-conversation')) {
+ expect(node).toHaveTextContent(`channel:${publicChannel.key}:Public`);
+ }
+ });
+ });
});
diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx
index 2815201..915e060 100644
--- a/frontend/src/test/settingsModal.test.tsx
+++ b/frontend/src/test/settingsModal.test.tsx
@@ -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: [
diff --git a/frontend/src/utils/lastViewedConversation.ts b/frontend/src/utils/lastViewedConversation.ts
new file mode 100644
index 0000000..1bd7967
--- /dev/null
+++ b/frontend/src/utils/lastViewedConversation.ts
@@ -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;
+ 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,
+ });
+}
diff --git a/tests/e2e/specs/reopen-last-conversation.spec.ts b/tests/e2e/specs/reopen-last-conversation.spec.ts
new file mode 100644
index 0000000..091bc03
--- /dev/null
+++ b/tests/e2e/specs/reopen-last-conversation.spec.ts
@@ -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();
+ });
+});