diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a571db6..43ec9d6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -59,11 +59,13 @@ import type { } from './types'; const MAX_RAW_PACKETS = 500; +const PUBLIC_CHANNEL_KEY = '8B3387E9C5CDEA6AC9E5EDBAA115CD72'; export function App() { const messageInputRef = useRef(null); const activeConversationRef = useRef(null); const rebootPollTokenRef = useRef(0); + const pendingDeleteFallbackRef = useRef(false); // Track seen message content to prevent duplicate unread increments // Uses content-based key (type-conversation_key-text-sender_timestamp) for deduplication const seenMessageContentRef = useRef>(new Set()); @@ -497,6 +499,29 @@ export function App() { } }, [activeConversation]); + // If a delete action left us without an active conversation, recover to Public + // once channels are available. This is scoped to delete flows only so it doesn't + // interfere with hash-based startup resolution. + useEffect(() => { + if (!pendingDeleteFallbackRef.current) return; + if (activeConversation) { + pendingDeleteFallbackRef.current = false; + return; + } + + const publicChannel = + channels.find((c) => c.key === PUBLIC_CHANNEL_KEY) || channels.find((c) => c.name === 'Public'); + if (!publicChannel) return; + + hasSetDefaultConversation.current = true; + pendingDeleteFallbackRef.current = false; + setActiveConversation({ + type: 'channel', + id: publicChannel.key, + name: publicChannel.name, + }); + }, [activeConversation, channels]); + // Send message handler const handleSendMessage = useCallback( async (text: string) => { @@ -638,10 +663,20 @@ export function App() { const handleDeleteChannel = useCallback(async (key: string) => { if (!confirm('Delete this channel? Message history will be preserved.')) return; try { + pendingDeleteFallbackRef.current = true; await api.deleteChannel(key); messageCache.remove(key); - setChannels((prev) => prev.filter((c) => c.key !== key)); - setActiveConversation(null); + const refreshedChannels = await api.getChannels(); + setChannels(refreshedChannels); + const publicChannel = + refreshedChannels.find((c) => c.key === PUBLIC_CHANNEL_KEY) || + refreshedChannels.find((c) => c.name === 'Public'); + hasSetDefaultConversation.current = true; + setActiveConversation({ + type: 'channel', + id: publicChannel?.key || PUBLIC_CHANNEL_KEY, + name: publicChannel?.name || 'Public', + }); toast.success('Channel deleted'); } catch (err) { console.error('Failed to delete channel:', err); @@ -655,10 +690,21 @@ export function App() { const handleDeleteContact = useCallback(async (publicKey: string) => { if (!confirm('Delete this contact? Message history will be preserved.')) return; try { + pendingDeleteFallbackRef.current = true; await api.deleteContact(publicKey); messageCache.remove(publicKey); setContacts((prev) => prev.filter((c) => c.public_key !== publicKey)); - setActiveConversation(null); + const refreshedChannels = await api.getChannels(); + setChannels(refreshedChannels); + const publicChannel = + refreshedChannels.find((c) => c.key === PUBLIC_CHANNEL_KEY) || + refreshedChannels.find((c) => c.name === 'Public'); + hasSetDefaultConversation.current = true; + setActiveConversation({ + type: 'channel', + id: publicChannel?.key || PUBLIC_CHANNEL_KEY, + name: publicChannel?.name || 'Public', + }); toast.success('Contact deleted'); } catch (err) { console.error('Failed to delete contact:', err); diff --git a/tests/e2e/helpers/api.ts b/tests/e2e/helpers/api.ts index ec7f031..4c1806e 100644 --- a/tests/e2e/helpers/api.ts +++ b/tests/e2e/helpers/api.ts @@ -81,6 +81,47 @@ export function deleteChannel(key: string): Promise { return fetchJson(`/channels/${key}`, { method: 'DELETE' }); } +// --- Contacts --- + +export interface Contact { + public_key: string; + name: string | null; + type: number; + flags: number; + last_path: string | null; + last_path_len: number; + last_advert: number | null; + lat: number | null; + lon: number | null; + last_seen: number | null; + on_radio: boolean; + last_contacted: number | null; + last_read_at: number | null; +} + +export function getContacts(limit: number = 100, offset: number = 0): Promise { + return fetchJson(`/contacts?limit=${limit}&offset=${offset}`); +} + +export function createContact( + publicKey: string, + name?: string, + tryHistorical: boolean = false +): Promise { + return fetchJson('/contacts', { + method: 'POST', + body: JSON.stringify({ + public_key: publicKey, + ...(name ? { name } : {}), + try_historical: tryHistorical, + }), + }); +} + +export function deleteContact(publicKey: string): Promise<{ status: string }> { + return fetchJson(`/contacts/${publicKey}`, { method: 'DELETE' }); +} + // --- Messages --- export interface MessagePath { diff --git a/tests/e2e/specs/conversation-delete.spec.ts b/tests/e2e/specs/conversation-delete.spec.ts new file mode 100644 index 0000000..114ff99 --- /dev/null +++ b/tests/e2e/specs/conversation-delete.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from '@playwright/test'; +import { createChannel, deleteChannel, getChannels } from '../helpers/api'; + +test.describe('Conversation deletion flow', () => { + test.beforeAll(async () => { + const channels = await getChannels(); + if (!channels.some((c) => c.name === 'Public')) { + await createChannel('Public'); + } + }); + + test('deleting active channel removes it from sidebar and clears composer', async ({ page }) => { + const channelName = `#e2edel${Date.now().toString().slice(-6)}`; + const channel = await createChannel(channelName); + + 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+${channelName}`, 'i'))).toBeVisible(); + + page.once('dialog', async (dialog) => { + await dialog.accept(); + }); + await page.getByTitle('Delete').click(); + + await expect(page.getByText('Channel deleted')).toBeVisible(); + await expect(page.getByText(channelName, { exact: true })).not.toBeVisible(); + await expect(page.getByPlaceholder(new RegExp(`message\\s+${channelName}`, 'i'))).not.toBeVisible(); + + try { + await deleteChannel(channel.key); + } catch { + // Best-effort cleanup + } + }); + + test('deleting active channel falls back to Public conversation', async ({ page }) => { + const channelName = `#e2edel${Date.now().toString().slice(-6)}`; + const channel = await createChannel(channelName); + + 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+${channelName}`, 'i'))).toBeVisible(); + + page.once('dialog', async (dialog) => { + await dialog.accept(); + }); + await page.getByTitle('Delete').click(); + + await expect(page.getByPlaceholder(/message\s+public/i)).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/tests/e2e/specs/favorites.spec.ts b/tests/e2e/specs/favorites.spec.ts new file mode 100644 index 0000000..4fd671f --- /dev/null +++ b/tests/e2e/specs/favorites.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from '@playwright/test'; +import { + createChannel, + deleteChannel, + getSettings, + updateSettings, + type Favorite, +} from '../helpers/api'; + +test.describe('Favorites persistence', () => { + let originalFavorites: Favorite[] = []; + let channelName = ''; + let channelKey = ''; + + test.beforeAll(async () => { + const settings = await getSettings(); + originalFavorites = settings.favorites ?? []; + + // Start deterministic: no favorites + await updateSettings({ favorites: [] }); + + channelName = `#e2efav${Date.now().toString().slice(-6)}`; + const channel = await createChannel(channelName); + channelKey = channel.key; + }); + + test.afterAll(async () => { + try { + await deleteChannel(channelKey); + } catch { + // Best-effort cleanup + } + try { + await updateSettings({ favorites: originalFavorites }); + } catch { + // Best-effort cleanup + } + }); + + test('add and remove favorite channel with persistence across reload', async ({ page }) => { + await page.goto('/'); + await expect(page.getByText('Connected')).toBeVisible(); + + await page.getByText(channelName, { exact: true }).first().click(); + + const addFavoriteButton = page.getByTitle('Add to favorites'); + await expect(addFavoriteButton).toBeVisible(); + await addFavoriteButton.click(); + + await expect(page.getByTitle('Remove from favorites')).toBeVisible(); + await expect(page.getByText('Favorites')).toBeVisible(); + await expect + .poll(async () => { + const settings = await getSettings(); + return settings.favorites.some((f) => f.type === 'channel' && f.id === channelKey); + }) + .toBe(true); + + await page.reload(); + await expect(page.getByText('Connected')).toBeVisible(); + await page.getByText(channelName, { exact: true }).first().click(); + await expect(page.getByTitle('Remove from favorites')).toBeVisible(); + await expect(page.getByText('Favorites')).toBeVisible(); + + await page.getByTitle('Remove from favorites').click(); + await expect(page.getByTitle('Add to favorites')).toBeVisible(); + await expect + .poll(async () => { + const settings = await getSettings(); + return settings.favorites.some((f) => f.type === 'channel' && f.id === channelKey); + }) + .toBe(false); + await expect(page.getByText('Favorites')).not.toBeVisible(); + }); +}); diff --git a/tests/e2e/specs/hash-routing.spec.ts b/tests/e2e/specs/hash-routing.spec.ts new file mode 100644 index 0000000..fae0356 --- /dev/null +++ b/tests/e2e/specs/hash-routing.spec.ts @@ -0,0 +1,95 @@ +import { test, expect } from '@playwright/test'; +import { randomBytes } from 'crypto'; +import { createChannel, createContact, deleteChannel, deleteContact } from '../helpers/api'; + +function randomHex(bytes: number): string { + return randomBytes(bytes).toString('hex'); +} + +function makeKeyWithPrefix(prefix: string): string { + return `${prefix}${randomHex(26)}`; +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +test.describe('Hash routing and conversation identity', () => { + let channelName = ''; + let channelKey = ''; + let contactAKey = ''; + let contactAName = ''; + let contactBKey = ''; + let contactBName = ''; + + test.beforeAll(async () => { + channelName = `#e2ehash${Date.now().toString().slice(-6)}`; + const createdChannel = await createChannel(channelName); + channelKey = createdChannel.key; + + const sharedPrefix = randomHex(6); + contactAKey = makeKeyWithPrefix(sharedPrefix); + contactBKey = makeKeyWithPrefix(sharedPrefix); + contactAName = `E2E Hash A ${Date.now().toString().slice(-5)}`; + contactBName = `E2E Hash B ${Date.now().toString().slice(-5)}`; + + await createContact(contactAKey, contactAName); + await createContact(contactBKey, contactBName); + }); + + test.afterAll(async () => { + try { + await deleteChannel(channelKey); + } catch { + // Best-effort cleanup + } + try { + await deleteContact(contactAKey); + } catch { + // Best-effort cleanup + } + try { + await deleteContact(contactBKey); + } catch { + // Best-effort cleanup + } + }); + + test('legacy channel-name hash resolves and rewrites to stable channel-key hash', async ({ + page, + }) => { + const legacyToken = channelName.slice(1); // no leading '#' + await page.goto(`/#channel/${encodeURIComponent(legacyToken)}`); + + await expect(page.getByText('Connected')).toBeVisible(); + await expect( + page.getByPlaceholder(new RegExp(`message\\s+${escapeRegex(channelName)}`, 'i')) + ).toBeVisible(); + + await expect.poll(() => page.url()).toContain(`#channel/${encodeURIComponent(channelKey)}/`); + }); + + test('full-key contact hash selects the exact contact even with shared prefixes', async ({ page }) => { + await page.goto(`/#contact/${contactBKey}`); + + await expect(page.getByText('Connected')).toBeVisible(); + await expect( + page.getByPlaceholder(new RegExp(`message\\s+${escapeRegex(contactBName)}`, 'i')) + ).toBeVisible(); + await expect(page.getByText(contactBKey, { exact: true })).toBeVisible(); + + await expect.poll(() => page.url()).toContain(`#contact/${encodeURIComponent(contactBKey)}/`); + }); + + test('legacy contact-name hash resolves and rewrites to stable full-key hash', async ({ page }) => { + await page.goto(`/#contact/${encodeURIComponent(contactAName)}`); + + await expect(page.getByText('Connected')).toBeVisible(); + await expect( + page.getByPlaceholder(new RegExp(`message\\s+${escapeRegex(contactAName)}`, 'i')) + ).toBeVisible(); + await expect(page.getByText(contactAKey, { exact: true })).toBeVisible(); + + await expect.poll(() => page.url()).toContain(`#contact/${encodeURIComponent(contactAKey)}/`); + }); +}); diff --git a/tests/e2e/specs/incoming-message.spec.ts b/tests/e2e/specs/incoming-message.spec.ts index b12e307..e0e7547 100644 --- a/tests/e2e/specs/incoming-message.spec.ts +++ b/tests/e2e/specs/incoming-message.spec.ts @@ -158,8 +158,8 @@ test.describe('Incoming mesh messages', () => { await expect(modal).toBeVisible(); // Verify the modal has the basic structural elements every path modal should have - await expect(modal.getByText('Sender')).toBeVisible(); - await expect(modal.getByText('Receiver (me)')).toBeVisible(); + await expect(modal.getByText('Sender:').first()).toBeVisible(); + await expect(modal.getByText('Receiver (me):').first()).toBeVisible(); // Title should be either "Message Path" (single) or "Message Paths (N)" (multiple) const titleEl = modal.locator('h2, [class*="DialogTitle"]').first();