Complete room -> channel rename

This commit is contained in:
jkingsman
2026-03-24 14:02:43 -07:00
parent e8a4f5c349
commit d36c63f6b1
17 changed files with 92 additions and 89 deletions

View File

@@ -45,8 +45,8 @@ export function ChannelFloodScopeOverrideModal({
<DialogHeader>
<DialogTitle>Regional Override</DialogTitle>
<DialogDescription>
Room-level regional routing temporarily changes the radio flood scope before send and
restores it after. This can noticeably slow room sends.
Channel-level regional routing temporarily changes the radio flood scope before send and
restores it after. This can noticeably slow channel sends.
</DialogDescription>
</DialogHeader>

View File

@@ -201,7 +201,9 @@ export function ChatHeader({
e.stopPropagation();
navigator.clipboard.writeText(conversation.id);
toast.success(
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
conversation.type === 'channel'
? 'Channel key copied!'
: 'Contact key copied!'
);
}}
title="Click to copy"

View File

@@ -242,8 +242,8 @@ export function ContactInfoPane({
<ActivityChartsSection analytics={analytics} />
<MostActiveRoomsSection
rooms={analytics?.most_active_rooms ?? []}
<MostActiveChannelsSection
channels={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel}
/>
</div>
@@ -515,8 +515,8 @@ export function ContactInfoPane({
<ActivityChartsSection analytics={analytics} />
<MostActiveRoomsSection
rooms={analytics?.most_active_rooms ?? []}
<MostActiveChannelsSection
channels={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel}
/>
</div>
@@ -588,23 +588,23 @@ function MessageStatsSection({
);
}
function MostActiveRoomsSection({
rooms,
function MostActiveChannelsSection({
channels,
onNavigateToChannel,
}: {
rooms: ContactActiveRoom[];
channels: ContactActiveRoom[];
onNavigateToChannel?: (channelKey: string) => void;
}) {
if (rooms.length === 0) {
if (channels.length === 0) {
return null;
}
return (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Most Active Rooms</SectionLabel>
<SectionLabel>Most Active Channels</SectionLabel>
<div className="space-y-1">
{rooms.map((room) => (
<div key={room.channel_key} className="flex justify-between items-center text-sm">
{channels.map((channel) => (
<div key={channel.channel_key} className="flex justify-between items-center text-sm">
<span
className={
onNavigateToChannel
@@ -614,15 +614,15 @@ function MostActiveRoomsSection({
role={onNavigateToChannel ? 'button' : undefined}
tabIndex={onNavigateToChannel ? 0 : undefined}
onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined}
onClick={() => onNavigateToChannel?.(room.channel_key)}
onClick={() => onNavigateToChannel?.(channel.channel_key)}
>
{room.channel_name.startsWith('#') || isPublicChannelKey(room.channel_key)
? room.channel_name
: `#${room.channel_name}`}
{channel.channel_name.startsWith('#') || isPublicChannelKey(channel.channel_key)
? channel.channel_name
: `#${channel.channel_name}`}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{room.message_count.toLocaleString()} msg
{room.message_count !== 1 ? 's' : ''}
{channel.message_count.toLocaleString()} msg
{channel.message_count !== 1 ? 's' : ''}
</span>
</div>
))}

View File

@@ -7,8 +7,8 @@ import { toast } from './ui/sonner';
import { cn } from '@/lib/utils';
import { extractPacketPayloadHex } from '../utils/pathUtils';
interface CrackedRoom {
roomName: string;
interface CrackedChannel {
channelName: string;
key: string;
packetId: number;
message: string;
@@ -45,7 +45,7 @@ export function CrackerPanel({
const [twoWordMode, setTwoWordMode] = useState(false);
const [progress, setProgress] = useState<ProgressReport | null>(null);
const [queue, setQueue] = useState<Map<number, QueueItem>>(new Map());
const [crackedRooms, setCrackedRooms] = useState<CrackedRoom[]>([]);
const [crackedChannels, setCrackedChannels] = useState<CrackedChannel[]>([]);
const [wordlistLoaded, setWordlistLoaded] = useState(false);
const [gpuAvailable, setGpuAvailable] = useState<boolean | null>(null);
const [undecryptedPacketCount, setUndecryptedPacketCount] = useState<number | null>(null);
@@ -325,14 +325,14 @@ export function CrackerPanel({
return updated;
});
const newRoom: CrackedRoom = {
roomName: result.roomName,
const newCracked: CrackedChannel = {
channelName: result.roomName,
key: result.key,
packetId: nextId!,
message: result.decryptedMessage || '',
crackedAt: Date.now(),
};
setCrackedRooms((prev) => [...prev, newRoom]);
setCrackedChannels((prev) => [...prev, newCracked]);
// Auto-add channel if not already exists
const keyUpper = result.key.toUpperCase();
@@ -580,20 +580,20 @@ export function CrackerPanel({
</div>
)}
{/* Cracked rooms list */}
{crackedRooms.length > 0 && (
{/* Cracked channels list */}
{crackedChannels.length > 0 && (
<div>
<div className="text-xs text-muted-foreground mb-1">Cracked Rooms:</div>
<div className="text-xs text-muted-foreground mb-1">Cracked Channels:</div>
<div className="space-y-1">
{crackedRooms.map((room, i) => (
{crackedChannels.map((channel, i) => (
<div
key={i}
className="text-sm bg-success/10 border border-success/20 rounded px-2 py-1"
>
<span className="text-success font-medium">#{room.roomName}</span>
<span className="text-success font-medium">#{channel.channelName}</span>
<span className="text-muted-foreground ml-2 text-xs">
"{room.message.slice(0, 50)}
{room.message.length > 50 ? '...' : ''}"
"{channel.message.slice(0, 50)}
{channel.message.length > 50 ? '...' : ''}"
</span>
</div>
))}
@@ -604,8 +604,8 @@ export function CrackerPanel({
<hr className="border-border" />
<p className="text-sm text-muted-foreground leading-relaxed">
For unknown-keyed GroupText packets, this will attempt to dictionary attack, then brute
force payloads as they arrive, testing room names up to the specified length to discover
active rooms on the local mesh (GroupText packets may not be hashtag messages; we have no
force payloads as they arrive, testing channel names up to the specified length to discover
active channels on the local mesh (GroupText packets may not be hashtag messages; we have no
way of knowing but try as if they are).
<strong> Retry failed at n+1</strong> will let the cracker return to the failed queue and
pick up messages it couldn't crack, attempting them at one longer length.
@@ -613,8 +613,8 @@ export function CrackerPanel({
concatenated together (e.g. "hello" + "world" = "#helloworld") after the single-word
dictionary pass; this can substantially increase search time and also result in
false-positives.
<strong> Decrypt historical</strong> will run an async job on any room name it finds to see
if any historically captured packets will decrypt with that key.
<strong> Decrypt historical</strong> will run an async job on any channel name it finds to
see if any historically captured packets will decrypt with that key.
<strong> Turbo mode</strong> will push your GPU to the max (target dispatch time of 10s) and
may allow accelerated cracking and/or system instability.
</p>

View File

@@ -15,7 +15,7 @@ import { Checkbox } from './ui/checkbox';
import { Button } from './ui/button';
import { toast } from './ui/sonner';
type Tab = 'new-contact' | 'new-room' | 'hashtag';
type Tab = 'new-contact' | 'new-channel' | 'hashtag';
interface NewMessageModalProps {
open: boolean;
@@ -37,7 +37,7 @@ export function NewMessageModal({
const [tab, setTab] = useState<Tab>('new-contact');
const [name, setName] = useState('');
const [contactKey, setContactKey] = useState('');
const [roomKey, setRoomKey] = useState('');
const [channelKey, setChannelKey] = useState('');
const [tryHistorical, setTryHistorical] = useState(false);
const [permitCapitals, setPermitCapitals] = useState(false);
const [error, setError] = useState('');
@@ -47,7 +47,7 @@ export function NewMessageModal({
const resetForm = () => {
setName('');
setContactKey('');
setRoomKey('');
setChannelKey('');
setTryHistorical(false);
setPermitCapitals(false);
setError('');
@@ -65,12 +65,12 @@ export function NewMessageModal({
}
// handleCreateContact sets activeConversation with the backend-normalized key
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
} else if (tab === 'new-room') {
if (!name.trim() || !roomKey.trim()) {
setError('Room name and key are required');
} else if (tab === 'new-channel') {
if (!name.trim() || !channelKey.trim()) {
setError('Channel name and key are required');
return;
}
await onCreateChannel(name.trim(), roomKey.trim(), tryHistorical);
await onCreateChannel(name.trim(), channelKey.trim(), tryHistorical);
} else if (tab === 'hashtag') {
const channelName = name.trim();
const validationError = validateHashtagName(channelName);
@@ -147,7 +147,7 @@ export function NewMessageModal({
<DialogTitle>New Conversation</DialogTitle>
<DialogDescription className="sr-only">
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
{tab === 'new-room' && 'Create a private room with a shared encryption key'}
{tab === 'new-channel' && 'Create a private channel with a shared encryption key'}
{tab === 'hashtag' && 'Join a public hashtag channel'}
</DialogDescription>
</DialogHeader>
@@ -162,7 +162,7 @@ export function NewMessageModal({
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="new-contact">Contact</TabsTrigger>
<TabsTrigger value="new-room">Private Channel</TabsTrigger>
<TabsTrigger value="new-channel">Private Channel</TabsTrigger>
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
</TabsList>
@@ -187,23 +187,23 @@ export function NewMessageModal({
</div>
</TabsContent>
<TabsContent value="new-room" className="mt-4 space-y-4">
<TabsContent value="new-channel" className="mt-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="room-name">Room Name</Label>
<Label htmlFor="channel-name">Channel Name</Label>
<Input
id="room-name"
id="channel-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Room name"
placeholder="Channel name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="room-key">Room Key</Label>
<Label htmlFor="channel-key">Channel Key</Label>
<div className="flex gap-2">
<Input
id="room-key"
value={roomKey}
onChange={(e) => setRoomKey(e.target.value)}
id="channel-key"
value={channelKey}
onChange={(e) => setChannelKey(e.target.value)}
placeholder="Pre-shared key (hex)"
className="flex-1"
/>
@@ -217,7 +217,7 @@ export function NewMessageModal({
const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
setRoomKey(hex);
setChannelKey(hex);
}}
title="Generate random key"
aria-label="Generate random key"
@@ -251,7 +251,7 @@ export function NewMessageModal({
onChange={(e) => setPermitCapitals(e.target.checked)}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Permit capitals in room key derivation</span>
<span className="text-sm">Permit capitals in channel key derivation</span>
</label>
<p className="text-xs text-muted-foreground pl-7">
Not recommended; most companions normalize to lowercase

View File

@@ -161,7 +161,7 @@ function buildGroupTextResolutionCandidates(channels: Channel[]): GroupTextResol
}));
}
function resolveGroupTextRoomName(
function resolveGroupTextChannelName(
payload: {
channelHash?: string;
cipherMac?: string;
@@ -211,15 +211,15 @@ function getPacketContext(
groupTextCandidates: GroupTextResolutionCandidate[]
) {
const fallbackSender = packet.decrypted_info?.sender ?? null;
const fallbackRoom = packet.decrypted_info?.channel_name ?? null;
const fallbackChannel = packet.decrypted_info?.channel_name ?? null;
if (!inspection.decoded?.payload.decoded) {
if (!fallbackSender && !fallbackRoom) {
if (!fallbackSender && !fallbackChannel) {
return null;
}
return {
title: fallbackRoom ? 'Room' : 'Context',
primary: fallbackRoom ?? 'Sender metadata available',
title: fallbackChannel ? 'Channel' : 'Context',
primary: fallbackChannel ?? 'Sender metadata available',
secondary: fallbackSender ? `Sender: ${fallbackSender}` : null,
};
}
@@ -231,11 +231,12 @@ function getPacketContext(
ciphertext?: string;
decrypted?: { sender?: string; message?: string };
};
const roomName = fallbackRoom ?? resolveGroupTextRoomName(payload, groupTextCandidates);
const channelName =
fallbackChannel ?? resolveGroupTextChannelName(payload, groupTextCandidates);
return {
title: roomName ? 'Room' : 'Channel',
title: 'Channel',
primary:
roomName ?? (payload.channelHash ? `Channel hash ${payload.channelHash}` : 'GroupText'),
channelName ?? (payload.channelHash ? `Channel hash ${payload.channelHash}` : 'GroupText'),
secondary: payload.decrypted?.sender
? `Sender: ${payload.decrypted.sender}`
: fallbackSender

View File

@@ -748,7 +748,7 @@ export function Sidebar({
icon: <LockOpen className="h-4 w-4" />,
label: (
<>
{showCracker ? 'Hide' : 'Show'} Room Finder
{showCracker ? 'Hide' : 'Show'} Channel Finder
<span
className={cn(
'ml-1 text-[11px]',

View File

@@ -150,7 +150,7 @@ describe('ContactInfoPane', () => {
});
});
it('loads name-only channel stats and most active rooms', async () => {
it('loads name-only channel stats and most active channels', async () => {
getContactAnalytics.mockResolvedValue(
createAnalytics(null, {
lookup_type: 'name',
@@ -188,7 +188,7 @@ describe('ContactInfoPane', () => {
expect(screen.getByText('Name First In Use')).toBeInTheDocument();
expect(screen.getByText('Messages Per Hour')).toBeInTheDocument();
expect(screen.getByText('Messages Per Week')).toBeInTheDocument();
expect(screen.getByText('Most Active Rooms')).toBeInTheDocument();
expect(screen.getByText('Most Active Channels')).toBeInTheDocument();
expect(screen.getByText('#ops')).toBeInTheDocument();
expect(
screen.getByText(/Name-only analytics include channel messages only/i)

View File

@@ -105,13 +105,13 @@ describe('NewMessageModal form reset', () => {
});
});
describe('new-room tab', () => {
describe('new-channel tab', () => {
it('clears name and key after successful Create', async () => {
const user = userEvent.setup();
renderModal();
await switchToTab(user, 'Private Channel');
await user.type(screen.getByPlaceholderText('Room name'), 'MyRoom');
await user.type(screen.getByPlaceholderText('Channel name'), 'MyRoom');
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'cc'.repeat(16));
await user.click(screen.getByRole('button', { name: 'Create' }));
@@ -128,7 +128,7 @@ describe('NewMessageModal form reset', () => {
renderModal();
await switchToTab(user, 'Private Channel');
await user.type(screen.getByPlaceholderText('Room name'), 'MyRoom');
await user.type(screen.getByPlaceholderText('Channel name'), 'MyRoom');
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'cc'.repeat(16));
await user.click(screen.getByRole('button', { name: 'Create' }));
@@ -142,7 +142,7 @@ describe('NewMessageModal form reset', () => {
});
describe('tab switching resets form', () => {
it('clears contact fields when switching to room tab', async () => {
it('clears contact fields when switching to channel tab', async () => {
const user = userEvent.setup();
renderModal();
await switchToTab(user, 'Contact');
@@ -153,18 +153,18 @@ describe('NewMessageModal form reset', () => {
// Switch to Private Channel tab — fields should reset
await switchToTab(user, 'Private Channel');
expect((screen.getByPlaceholderText('Room name') as HTMLInputElement).value).toBe('');
expect((screen.getByPlaceholderText('Channel name') as HTMLInputElement).value).toBe('');
expect((screen.getByPlaceholderText('Pre-shared key (hex)') as HTMLInputElement).value).toBe(
''
);
});
it('clears room fields when switching to hashtag tab', async () => {
it('clears channel fields when switching to hashtag tab', async () => {
const user = userEvent.setup();
renderModal();
await switchToTab(user, 'Private Channel');
await user.type(screen.getByPlaceholderText('Room name'), 'SecretRoom');
await user.type(screen.getByPlaceholderText('Channel name'), 'SecretRoom');
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'ff'.repeat(16));
await switchToTab(user, 'Hashtag Channel');

View File

@@ -361,7 +361,7 @@ describe('RawPacketFeedView', () => {
expect(screen.queryByText('Identity not resolvable')).not.toBeInTheDocument();
});
it('opens a packet detail modal from the raw feed and decrypts room messages when a key is loaded', () => {
it('opens a packet detail modal from the raw feed and decrypts channel messages when a key is loaded', () => {
renderView({
packets: [
{
@@ -392,7 +392,7 @@ describe('RawPacketFeedView', () => {
).toBeInTheDocument();
});
it('does not guess a room name when multiple loaded channels collide on the group hash', () => {
it('does not guess a channel name when multiple loaded channels collide on the group hash', () => {
renderView({
packets: [
{

View File

@@ -67,8 +67,8 @@ export function describeCiphertextStructure(
case PayloadType.GroupText:
return `Encrypted message content (${byteLength} bytes). Contains encrypted plaintext with this structure:
• Timestamp (4 bytes) - send time as unix timestamp
• Flags (1 byte) - room-message flags byte
• Message (remaining bytes) - UTF-8 room message text`;
• Flags (1 byte) - channel-message flags byte
• Message (remaining bytes) - UTF-8 channel message text`;
case PayloadType.TextMessage:
return `Encrypted message data (${byteLength} bytes). Contains encrypted plaintext with this structure:
• Timestamp (4 bytes) - send time as unix timestamp