diff --git a/frontend/src/components/ChannelInfoPane.tsx b/frontend/src/components/ChannelInfoPane.tsx index 1ebfaf1..2614de1 100644 --- a/frontend/src/components/ChannelInfoPane.tsx +++ b/frontend/src/components/ChannelInfoPane.tsx @@ -24,11 +24,13 @@ export function ChannelInfoPane({ }: ChannelInfoPaneProps) { const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(false); + const [showKey, setShowKey] = useState(false); // Get live channel data from channels array (real-time via WS) const liveChannel = channelKey ? (channels.find((c) => c.key === channelKey) ?? null) : null; useEffect(() => { + setShowKey(false); if (!channelKey) { setDetail(null); return; @@ -74,23 +76,33 @@ export function ChannelInfoPane({ {/* Header */}

- {channel.name.startsWith('#') || channel.name === 'Public' - ? channel.name - : `#${channel.name}`} + {channel.is_hashtag && !channel.name.startsWith('#') + ? `#${channel.name}` + : channel.name}

- { - navigator.clipboard.writeText(channel.key); - toast.success('Channel key copied!'); - }} - title="Click to copy" - > - {channel.key.toLowerCase()} - + {!channel.is_hashtag && !showKey ? ( + + ) : ( + { + navigator.clipboard.writeText(channel.key); + toast.success('Channel key copied!'); + }} + title="Click to copy" + > + {channel.key.toLowerCase()} + + )}
{channel.is_hashtag ? 'Hashtag' : 'Private Key'} diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index 4f6bd20..fbfebd4 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import { toast } from './ui/sonner'; import { isFavorite } from '../utils/favorites'; import { handleKeyboardActivate } from '../utils/a11y'; @@ -32,12 +33,21 @@ export function ChatHeader({ onOpenContactInfo, onOpenChannelInfo, }: ChatHeaderProps) { + const [showKey, setShowKey] = useState(false); + + useEffect(() => { + setShowKey(false); + }, [conversation.id]); + + const isPrivateChannel = + conversation.type === 'channel' && !channels.find((c) => c.key === conversation.id)?.is_hashtag; + const titleClickable = (conversation.type === 'contact' && onOpenContactInfo) || (conversation.type === 'channel' && onOpenChannelInfo); return (
- + {conversation.type === 'contact' && onOpenContactInfo && ( - { - e.stopPropagation(); - navigator.clipboard.writeText(conversation.id); - toast.success( - conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!' - ); - }} - title="Click to copy" - > - {conversation.type === 'channel' ? conversation.id.toLowerCase() : conversation.id} - + {isPrivateChannel && !showKey ? ( + + ) : ( + { + e.stopPropagation(); + navigator.clipboard.writeText(conversation.id); + toast.success( + conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!' + ); + }} + title="Click to copy" + > + {conversation.type === 'channel' ? conversation.id.toLowerCase() : conversation.id} + + )} {conversation.type === 'contact' && (() => { const contact = contacts.find((c) => c.public_key === conversation.id); diff --git a/frontend/src/test/channelInfoKeyVisibility.test.tsx b/frontend/src/test/channelInfoKeyVisibility.test.tsx new file mode 100644 index 0000000..2141756 --- /dev/null +++ b/frontend/src/test/channelInfoKeyVisibility.test.tsx @@ -0,0 +1,114 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { ChannelInfoPane } from '../components/ChannelInfoPane'; +import type { Channel, ChannelDetail, Favorite } from '../types'; + +// Mock the api module +vi.mock('../api', () => ({ + api: { + getChannelDetail: vi.fn(), + }, +})); + +import { api } from '../api'; +const mockGetChannelDetail = vi.mocked(api.getChannelDetail); + +function makeChannel(key: string, name: string, isHashtag: boolean): Channel { + return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null }; +} + +function makeDetail(channel: Channel): ChannelDetail { + return { + channel, + message_counts: { last_1h: 0, last_24h: 0, last_48h: 0, last_7d: 0, all_time: 0 }, + first_message_at: null, + unique_sender_count: 0, + top_senders_24h: [], + }; +} + +const noop = () => {}; + +const baseProps = { + onClose: noop, + favorites: [] as Favorite[], + onToggleFavorite: noop, +}; + +describe('ChannelInfoPane key visibility', () => { + it('shows key directly for hashtag channels', async () => { + const key = 'AA'.repeat(16); + const channel = makeChannel(key, '#general', true); + mockGetChannelDetail.mockResolvedValue(makeDetail(channel)); + + render(); + + await waitFor(() => { + expect(screen.getByText(key.toLowerCase())).toBeInTheDocument(); + }); + expect(screen.queryByText('Show Key')).not.toBeInTheDocument(); + }); + + it('hides key behind "Show Key" button for private channels', async () => { + const key = 'BB'.repeat(16); + const channel = makeChannel(key, 'Secret', false); + mockGetChannelDetail.mockResolvedValue(makeDetail(channel)); + + render(); + + await waitFor(() => { + expect(screen.getByText('Secret')).toBeInTheDocument(); + }); + expect(screen.queryByText(key.toLowerCase())).not.toBeInTheDocument(); + expect(screen.getByText('Show Key')).toBeInTheDocument(); + }); + + it('reveals key when "Show Key" is clicked', async () => { + const key = 'CC'.repeat(16); + const channel = makeChannel(key, 'Private', false); + mockGetChannelDetail.mockResolvedValue(makeDetail(channel)); + + render(); + + await waitFor(() => { + expect(screen.getByText('Show Key')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Show Key')); + + expect(screen.getByText(key.toLowerCase())).toBeInTheDocument(); + expect(screen.queryByText('Show Key')).not.toBeInTheDocument(); + }); + + it('resets key visibility when channel changes', async () => { + const key1 = 'DD'.repeat(16); + const key2 = 'EE'.repeat(16); + const ch1 = makeChannel(key1, 'Room1', false); + const ch2 = makeChannel(key2, 'Room2', false); + mockGetChannelDetail.mockImplementation((key) => + Promise.resolve(key === key1 ? makeDetail(ch1) : makeDetail(ch2)) + ); + + const { rerender } = render( + + ); + + await waitFor(() => { + expect(screen.getByText('Show Key')).toBeInTheDocument(); + }); + + // Reveal key for first channel + fireEvent.click(screen.getByText('Show Key')); + expect(screen.getByText(key1.toLowerCase())).toBeInTheDocument(); + + // Switch channel — key should be hidden again + rerender(); + + await waitFor(() => { + expect(screen.getByText('Room2')).toBeInTheDocument(); + }); + expect(screen.queryByText(key2.toLowerCase())).not.toBeInTheDocument(); + expect(screen.getByText('Show Key')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/chatHeaderKeyVisibility.test.tsx b/frontend/src/test/chatHeaderKeyVisibility.test.tsx new file mode 100644 index 0000000..40c3565 --- /dev/null +++ b/frontend/src/test/chatHeaderKeyVisibility.test.tsx @@ -0,0 +1,108 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { ChatHeader } from '../components/ChatHeader'; +import type { Channel, Conversation, Favorite } from '../types'; + +function makeChannel(key: string, name: string, isHashtag: boolean): Channel { + return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null }; +} + +const noop = () => {}; + +const baseProps = { + contacts: [], + config: null, + favorites: [] as Favorite[], + onTrace: noop, + onToggleFavorite: noop, + onDeleteChannel: noop, + onDeleteContact: noop, +}; + +describe('ChatHeader key visibility', () => { + it('shows key directly for hashtag channels', () => { + const key = 'AA'.repeat(16); + const channel = makeChannel(key, '#general', true); + const conversation: Conversation = { type: 'channel', id: key, name: '#general' }; + + render(); + + expect(screen.getByText(key.toLowerCase())).toBeInTheDocument(); + expect(screen.queryByText('Show Key')).not.toBeInTheDocument(); + }); + + it('hides key behind "Show Key" button for private channels', () => { + const key = 'BB'.repeat(16); + const channel = makeChannel(key, 'Secret Room', false); + const conversation: Conversation = { type: 'channel', id: key, name: 'Secret Room' }; + + render(); + + expect(screen.queryByText(key.toLowerCase())).not.toBeInTheDocument(); + expect(screen.getByText('Show Key')).toBeInTheDocument(); + }); + + it('reveals key when "Show Key" is clicked', () => { + const key = 'CC'.repeat(16); + const channel = makeChannel(key, 'Private', false); + const conversation: Conversation = { type: 'channel', id: key, name: 'Private' }; + + render(); + + fireEvent.click(screen.getByText('Show Key')); + + expect(screen.getByText(key.toLowerCase())).toBeInTheDocument(); + expect(screen.queryByText('Show Key')).not.toBeInTheDocument(); + }); + + it('resets key visibility when conversation changes', () => { + const key1 = 'DD'.repeat(16); + const key2 = 'EE'.repeat(16); + const ch1 = makeChannel(key1, 'Room1', false); + const ch2 = makeChannel(key2, 'Room2', false); + const conv1: Conversation = { type: 'channel', id: key1, name: 'Room1' }; + const conv2: Conversation = { type: 'channel', id: key2, name: 'Room2' }; + + const { rerender } = render( + + ); + + // Reveal key for first conversation + fireEvent.click(screen.getByText('Show Key')); + expect(screen.getByText(key1.toLowerCase())).toBeInTheDocument(); + + // Switch conversation — key should be hidden again + rerender(); + + expect(screen.queryByText(key2.toLowerCase())).not.toBeInTheDocument(); + expect(screen.getByText('Show Key')).toBeInTheDocument(); + }); + + it('shows key directly for contacts', () => { + const pubKey = '11'.repeat(32); + const conversation: Conversation = { type: 'contact', id: pubKey, name: 'Alice' }; + + render(); + + expect(screen.getByText(pubKey)).toBeInTheDocument(); + expect(screen.queryByText('Show Key')).not.toBeInTheDocument(); + }); + + it('copies key to clipboard when revealed key is clicked', async () => { + const key = 'FF'.repeat(16); + const channel = makeChannel(key, 'Priv', false); + const conversation: Conversation = { type: 'channel', id: key, name: 'Priv' }; + + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + render(); + + // Reveal key then click to copy + fireEvent.click(screen.getByText('Show Key')); + fireEvent.click(screen.getByText(key.toLowerCase())); + + expect(writeText).toHaveBeenCalledWith(key); + }); +});