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
+1 -1
View File
@@ -7,7 +7,7 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
* Run multiple Python bots that can analyze messages and respond to DMs and channels * Run multiple Python bots that can analyze messages and respond to DMs and channels
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side) * Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
* Access your radio remotely over your network or VPN * Access your radio remotely over your network or VPN
* Search for hashtag room names for channels you don't have keys for yet * Search for hashtag channel names for channels you don't have keys for yet
* Forward packets to MQTT, LetsMesh, MeshRank, SQS, Apprise, etc. * Forward packets to MQTT, LetsMesh, MeshRank, SQS, Apprise, etc.
* Use the more recent 1.14 firmwares which support multibyte pathing * Use the more recent 1.14 firmwares which support multibyte pathing
* Visualize the mesh as a map or node set, view repeater stats, and more! * Visualize the mesh as a map or node set, view repeater stats, and more!
+1 -1
View File
@@ -21,7 +21,7 @@ If the audit finds a mismatch, you'll see an error in the application UI and you
## HTTPS ## HTTPS
WebGPU room-finding requires a secure context when you are not on `localhost`. WebGPU channel-finding requires a secure context when you are not on `localhost`.
Generate a local cert and start the backend with TLS: Generate a local cert and start the backend with TLS:
+1 -1
View File
@@ -101,7 +101,7 @@ app/
- Packet `path_len` values are hop counts, not byte counts. - Packet `path_len` values are hop counts, not byte counts.
- Hop width comes from the packet or radio `path_hash_mode`: `0` = 1-byte, `1` = 2-byte, `2` = 3-byte. - Hop width comes from the packet or radio `path_hash_mode`: `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
- Channel slot count comes from firmware-reported `DEVICE_INFO.max_channels`; do not hardcode `40` when scanning/offloading channel slots. - Channel slot count comes from firmware-reported `DEVICE_INFO.max_channels`; do not hardcode `40` when scanning/offloading channel slots.
- Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same room reuse the loaded slot; new rooms fill free slots up to the discovered channel capacity, then evict the least recently used cached room. - Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same channel reuse the loaded slot; new channels fill free slots up to the discovered channel capacity, then evict the least recently used cached channel.
- TCP radios do not reuse cached slot contents. For TCP, channel sends still force `set_channel(...)` before every send because this backend does not have exclusive device access. - TCP radios do not reuse cached slot contents. For TCP, channel sends still force `set_channel(...)` before every send because this backend does not have exclusive device access.
- `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` disables slot reuse on all transports and forces the old always-`set_channel(...)` behavior before every channel send. - `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` disables slot reuse on all transports and forces the old always-`set_channel(...)` behavior before every channel send.
- Contacts persist canonical direct-route fields (`direct_path`, `direct_path_len`, `direct_path_hash_mode`) so contact sync and outbound DM routing reuse the exact stored hop width instead of inferring from path bytes. - Contacts persist canonical direct-route fields (`direct_path`, `direct_path_len`, `direct_path_hash_mode`) so contact sync and outbound DM routing reuse the exact stored hop width instead of inferring from path bytes.
+1 -1
View File
@@ -266,7 +266,7 @@ class ContactNameHistory(BaseModel):
class ContactActiveRoom(BaseModel): class ContactActiveRoom(BaseModel):
"""A channel/room where a contact has been active.""" """A channel where a contact has been active."""
channel_key: str channel_key: str
channel_name: str channel_name: str
+1 -1
View File
@@ -71,7 +71,7 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
requested_name = request.name requested_name = request.name
is_hashtag = requested_name.startswith("#") is_hashtag = requested_name.startswith("#")
# Reserve the canonical Public room so it cannot drift to another key, # Reserve the canonical Public channel so it cannot drift to another key,
# and the well-known Public key cannot be renamed to something else. # and the well-known Public key cannot be renamed to something else.
if is_public_channel_name(requested_name): if is_public_channel_name(requested_name):
if request.key: if request.key:
@@ -45,8 +45,8 @@ export function ChannelFloodScopeOverrideModal({
<DialogHeader> <DialogHeader>
<DialogTitle>Regional Override</DialogTitle> <DialogTitle>Regional Override</DialogTitle>
<DialogDescription> <DialogDescription>
Room-level regional routing temporarily changes the radio flood scope before send and Channel-level regional routing temporarily changes the radio flood scope before send and
restores it after. This can noticeably slow room sends. restores it after. This can noticeably slow channel sends.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
+3 -1
View File
@@ -201,7 +201,9 @@ export function ChatHeader({
e.stopPropagation(); e.stopPropagation();
navigator.clipboard.writeText(conversation.id); navigator.clipboard.writeText(conversation.id);
toast.success( toast.success(
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!' conversation.type === 'channel'
? 'Channel key copied!'
: 'Contact key copied!'
); );
}} }}
title="Click to copy" title="Click to copy"
+17 -17
View File
@@ -242,8 +242,8 @@ export function ContactInfoPane({
<ActivityChartsSection analytics={analytics} /> <ActivityChartsSection analytics={analytics} />
<MostActiveRoomsSection <MostActiveChannelsSection
rooms={analytics?.most_active_rooms ?? []} channels={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel} onNavigateToChannel={onNavigateToChannel}
/> />
</div> </div>
@@ -515,8 +515,8 @@ export function ContactInfoPane({
<ActivityChartsSection analytics={analytics} /> <ActivityChartsSection analytics={analytics} />
<MostActiveRoomsSection <MostActiveChannelsSection
rooms={analytics?.most_active_rooms ?? []} channels={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel} onNavigateToChannel={onNavigateToChannel}
/> />
</div> </div>
@@ -588,23 +588,23 @@ function MessageStatsSection({
); );
} }
function MostActiveRoomsSection({ function MostActiveChannelsSection({
rooms, channels,
onNavigateToChannel, onNavigateToChannel,
}: { }: {
rooms: ContactActiveRoom[]; channels: ContactActiveRoom[];
onNavigateToChannel?: (channelKey: string) => void; onNavigateToChannel?: (channelKey: string) => void;
}) { }) {
if (rooms.length === 0) { if (channels.length === 0) {
return null; return null;
} }
return ( return (
<div className="px-5 py-3 border-b border-border"> <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"> <div className="space-y-1">
{rooms.map((room) => ( {channels.map((channel) => (
<div key={room.channel_key} className="flex justify-between items-center text-sm"> <div key={channel.channel_key} className="flex justify-between items-center text-sm">
<span <span
className={ className={
onNavigateToChannel onNavigateToChannel
@@ -614,15 +614,15 @@ function MostActiveRoomsSection({
role={onNavigateToChannel ? 'button' : undefined} role={onNavigateToChannel ? 'button' : undefined}
tabIndex={onNavigateToChannel ? 0 : undefined} tabIndex={onNavigateToChannel ? 0 : undefined}
onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined} onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined}
onClick={() => onNavigateToChannel?.(room.channel_key)} onClick={() => onNavigateToChannel?.(channel.channel_key)}
> >
{room.channel_name.startsWith('#') || isPublicChannelKey(room.channel_key) {channel.channel_name.startsWith('#') || isPublicChannelKey(channel.channel_key)
? room.channel_name ? channel.channel_name
: `#${room.channel_name}`} : `#${channel.channel_name}`}
</span> </span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2"> <span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{room.message_count.toLocaleString()} msg {channel.message_count.toLocaleString()} msg
{room.message_count !== 1 ? 's' : ''} {channel.message_count !== 1 ? 's' : ''}
</span> </span>
</div> </div>
))} ))}
+17 -17
View File
@@ -7,8 +7,8 @@ import { toast } from './ui/sonner';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { extractPacketPayloadHex } from '../utils/pathUtils'; import { extractPacketPayloadHex } from '../utils/pathUtils';
interface CrackedRoom { interface CrackedChannel {
roomName: string; channelName: string;
key: string; key: string;
packetId: number; packetId: number;
message: string; message: string;
@@ -45,7 +45,7 @@ export function CrackerPanel({
const [twoWordMode, setTwoWordMode] = useState(false); const [twoWordMode, setTwoWordMode] = useState(false);
const [progress, setProgress] = useState<ProgressReport | null>(null); const [progress, setProgress] = useState<ProgressReport | null>(null);
const [queue, setQueue] = useState<Map<number, QueueItem>>(new Map()); 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 [wordlistLoaded, setWordlistLoaded] = useState(false);
const [gpuAvailable, setGpuAvailable] = useState<boolean | null>(null); const [gpuAvailable, setGpuAvailable] = useState<boolean | null>(null);
const [undecryptedPacketCount, setUndecryptedPacketCount] = useState<number | null>(null); const [undecryptedPacketCount, setUndecryptedPacketCount] = useState<number | null>(null);
@@ -325,14 +325,14 @@ export function CrackerPanel({
return updated; return updated;
}); });
const newRoom: CrackedRoom = { const newCracked: CrackedChannel = {
roomName: result.roomName, channelName: result.roomName,
key: result.key, key: result.key,
packetId: nextId!, packetId: nextId!,
message: result.decryptedMessage || '', message: result.decryptedMessage || '',
crackedAt: Date.now(), crackedAt: Date.now(),
}; };
setCrackedRooms((prev) => [...prev, newRoom]); setCrackedChannels((prev) => [...prev, newCracked]);
// Auto-add channel if not already exists // Auto-add channel if not already exists
const keyUpper = result.key.toUpperCase(); const keyUpper = result.key.toUpperCase();
@@ -580,20 +580,20 @@ export function CrackerPanel({
</div> </div>
)} )}
{/* Cracked rooms list */} {/* Cracked channels list */}
{crackedRooms.length > 0 && ( {crackedChannels.length > 0 && (
<div> <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"> <div className="space-y-1">
{crackedRooms.map((room, i) => ( {crackedChannels.map((channel, i) => (
<div <div
key={i} key={i}
className="text-sm bg-success/10 border border-success/20 rounded px-2 py-1" 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"> <span className="text-muted-foreground ml-2 text-xs">
"{room.message.slice(0, 50)} "{channel.message.slice(0, 50)}
{room.message.length > 50 ? '...' : ''}" {channel.message.length > 50 ? '...' : ''}"
</span> </span>
</div> </div>
))} ))}
@@ -604,8 +604,8 @@ export function CrackerPanel({
<hr className="border-border" /> <hr className="border-border" />
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
For unknown-keyed GroupText packets, this will attempt to dictionary attack, then brute 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 force payloads as they arrive, testing channel names up to the specified length to discover
active rooms on the local mesh (GroupText packets may not be hashtag messages; we have no 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). 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 <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. 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 concatenated together (e.g. "hello" + "world" = "#helloworld") after the single-word
dictionary pass; this can substantially increase search time and also result in dictionary pass; this can substantially increase search time and also result in
false-positives. false-positives.
<strong> Decrypt historical</strong> will run an async job on any room name it finds to see <strong> Decrypt historical</strong> will run an async job on any channel name it finds to
if any historically captured packets will decrypt with that key. 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 <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. may allow accelerated cracking and/or system instability.
</p> </p>
+19 -19
View File
@@ -15,7 +15,7 @@ import { Checkbox } from './ui/checkbox';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { toast } from './ui/sonner'; import { toast } from './ui/sonner';
type Tab = 'new-contact' | 'new-room' | 'hashtag'; type Tab = 'new-contact' | 'new-channel' | 'hashtag';
interface NewMessageModalProps { interface NewMessageModalProps {
open: boolean; open: boolean;
@@ -37,7 +37,7 @@ export function NewMessageModal({
const [tab, setTab] = useState<Tab>('new-contact'); const [tab, setTab] = useState<Tab>('new-contact');
const [name, setName] = useState(''); const [name, setName] = useState('');
const [contactKey, setContactKey] = useState(''); const [contactKey, setContactKey] = useState('');
const [roomKey, setRoomKey] = useState(''); const [channelKey, setChannelKey] = useState('');
const [tryHistorical, setTryHistorical] = useState(false); const [tryHistorical, setTryHistorical] = useState(false);
const [permitCapitals, setPermitCapitals] = useState(false); const [permitCapitals, setPermitCapitals] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -47,7 +47,7 @@ export function NewMessageModal({
const resetForm = () => { const resetForm = () => {
setName(''); setName('');
setContactKey(''); setContactKey('');
setRoomKey(''); setChannelKey('');
setTryHistorical(false); setTryHistorical(false);
setPermitCapitals(false); setPermitCapitals(false);
setError(''); setError('');
@@ -65,12 +65,12 @@ export function NewMessageModal({
} }
// handleCreateContact sets activeConversation with the backend-normalized key // handleCreateContact sets activeConversation with the backend-normalized key
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical); await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
} else if (tab === 'new-room') { } else if (tab === 'new-channel') {
if (!name.trim() || !roomKey.trim()) { if (!name.trim() || !channelKey.trim()) {
setError('Room name and key are required'); setError('Channel name and key are required');
return; return;
} }
await onCreateChannel(name.trim(), roomKey.trim(), tryHistorical); await onCreateChannel(name.trim(), channelKey.trim(), tryHistorical);
} else if (tab === 'hashtag') { } else if (tab === 'hashtag') {
const channelName = name.trim(); const channelName = name.trim();
const validationError = validateHashtagName(channelName); const validationError = validateHashtagName(channelName);
@@ -147,7 +147,7 @@ export function NewMessageModal({
<DialogTitle>New Conversation</DialogTitle> <DialogTitle>New Conversation</DialogTitle>
<DialogDescription className="sr-only"> <DialogDescription className="sr-only">
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'} {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'} {tab === 'hashtag' && 'Join a public hashtag channel'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -162,7 +162,7 @@ export function NewMessageModal({
> >
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="new-contact">Contact</TabsTrigger> <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> <TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
</TabsList> </TabsList>
@@ -187,23 +187,23 @@ export function NewMessageModal({
</div> </div>
</TabsContent> </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"> <div className="space-y-2">
<Label htmlFor="room-name">Room Name</Label> <Label htmlFor="channel-name">Channel Name</Label>
<Input <Input
id="room-name" id="channel-name"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="Room name" placeholder="Channel name"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="room-key">Room Key</Label> <Label htmlFor="channel-key">Channel Key</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
id="room-key" id="channel-key"
value={roomKey} value={channelKey}
onChange={(e) => setRoomKey(e.target.value)} onChange={(e) => setChannelKey(e.target.value)}
placeholder="Pre-shared key (hex)" placeholder="Pre-shared key (hex)"
className="flex-1" className="flex-1"
/> />
@@ -217,7 +217,7 @@ export function NewMessageModal({
const hex = Array.from(bytes) const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0')) .map((b) => b.toString(16).padStart(2, '0'))
.join(''); .join('');
setRoomKey(hex); setChannelKey(hex);
}} }}
title="Generate random key" title="Generate random key"
aria-label="Generate random key" aria-label="Generate random key"
@@ -251,7 +251,7 @@ export function NewMessageModal({
onChange={(e) => setPermitCapitals(e.target.checked)} onChange={(e) => setPermitCapitals(e.target.checked)}
className="w-4 h-4 rounded border-input accent-primary" 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> </label>
<p className="text-xs text-muted-foreground pl-7"> <p className="text-xs text-muted-foreground pl-7">
Not recommended; most companions normalize to lowercase Not recommended; most companions normalize to lowercase
@@ -161,7 +161,7 @@ function buildGroupTextResolutionCandidates(channels: Channel[]): GroupTextResol
})); }));
} }
function resolveGroupTextRoomName( function resolveGroupTextChannelName(
payload: { payload: {
channelHash?: string; channelHash?: string;
cipherMac?: string; cipherMac?: string;
@@ -211,15 +211,15 @@ function getPacketContext(
groupTextCandidates: GroupTextResolutionCandidate[] groupTextCandidates: GroupTextResolutionCandidate[]
) { ) {
const fallbackSender = packet.decrypted_info?.sender ?? null; 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 (!inspection.decoded?.payload.decoded) {
if (!fallbackSender && !fallbackRoom) { if (!fallbackSender && !fallbackChannel) {
return null; return null;
} }
return { return {
title: fallbackRoom ? 'Room' : 'Context', title: fallbackChannel ? 'Channel' : 'Context',
primary: fallbackRoom ?? 'Sender metadata available', primary: fallbackChannel ?? 'Sender metadata available',
secondary: fallbackSender ? `Sender: ${fallbackSender}` : null, secondary: fallbackSender ? `Sender: ${fallbackSender}` : null,
}; };
} }
@@ -231,11 +231,12 @@ function getPacketContext(
ciphertext?: string; ciphertext?: string;
decrypted?: { sender?: string; message?: string }; decrypted?: { sender?: string; message?: string };
}; };
const roomName = fallbackRoom ?? resolveGroupTextRoomName(payload, groupTextCandidates); const channelName =
fallbackChannel ?? resolveGroupTextChannelName(payload, groupTextCandidates);
return { return {
title: roomName ? 'Room' : 'Channel', title: 'Channel',
primary: primary:
roomName ?? (payload.channelHash ? `Channel hash ${payload.channelHash}` : 'GroupText'), channelName ?? (payload.channelHash ? `Channel hash ${payload.channelHash}` : 'GroupText'),
secondary: payload.decrypted?.sender secondary: payload.decrypted?.sender
? `Sender: ${payload.decrypted.sender}` ? `Sender: ${payload.decrypted.sender}`
: fallbackSender : fallbackSender
+1 -1
View File
@@ -748,7 +748,7 @@ export function Sidebar({
icon: <LockOpen className="h-4 w-4" />, icon: <LockOpen className="h-4 w-4" />,
label: ( label: (
<> <>
{showCracker ? 'Hide' : 'Show'} Room Finder {showCracker ? 'Hide' : 'Show'} Channel Finder
<span <span
className={cn( className={cn(
'ml-1 text-[11px]', 'ml-1 text-[11px]',
+2 -2
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( getContactAnalytics.mockResolvedValue(
createAnalytics(null, { createAnalytics(null, {
lookup_type: 'name', lookup_type: 'name',
@@ -188,7 +188,7 @@ describe('ContactInfoPane', () => {
expect(screen.getByText('Name First In Use')).toBeInTheDocument(); expect(screen.getByText('Name First In Use')).toBeInTheDocument();
expect(screen.getByText('Messages Per Hour')).toBeInTheDocument(); expect(screen.getByText('Messages Per Hour')).toBeInTheDocument();
expect(screen.getByText('Messages Per Week')).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('#ops')).toBeInTheDocument();
expect( expect(
screen.getByText(/Name-only analytics include channel messages only/i) screen.getByText(/Name-only analytics include channel messages only/i)
+7 -7
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 () => { it('clears name and key after successful Create', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderModal(); renderModal();
await switchToTab(user, 'Private Channel'); 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.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'cc'.repeat(16));
await user.click(screen.getByRole('button', { name: 'Create' })); await user.click(screen.getByRole('button', { name: 'Create' }));
@@ -128,7 +128,7 @@ describe('NewMessageModal form reset', () => {
renderModal(); renderModal();
await switchToTab(user, 'Private Channel'); 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.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'cc'.repeat(16));
await user.click(screen.getByRole('button', { name: 'Create' })); await user.click(screen.getByRole('button', { name: 'Create' }));
@@ -142,7 +142,7 @@ describe('NewMessageModal form reset', () => {
}); });
describe('tab switching resets form', () => { 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(); const user = userEvent.setup();
renderModal(); renderModal();
await switchToTab(user, 'Contact'); await switchToTab(user, 'Contact');
@@ -153,18 +153,18 @@ describe('NewMessageModal form reset', () => {
// Switch to Private Channel tab — fields should reset // Switch to Private Channel tab — fields should reset
await switchToTab(user, 'Private Channel'); 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( 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(); const user = userEvent.setup();
renderModal(); renderModal();
await switchToTab(user, 'Private Channel'); 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 user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'ff'.repeat(16));
await switchToTab(user, 'Hashtag Channel'); await switchToTab(user, 'Hashtag Channel');
+2 -2
View File
@@ -361,7 +361,7 @@ describe('RawPacketFeedView', () => {
expect(screen.queryByText('Identity not resolvable')).not.toBeInTheDocument(); 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({ renderView({
packets: [ packets: [
{ {
@@ -392,7 +392,7 @@ describe('RawPacketFeedView', () => {
).toBeInTheDocument(); ).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({ renderView({
packets: [ packets: [
{ {
+2 -2
View File
@@ -67,8 +67,8 @@ export function describeCiphertextStructure(
case PayloadType.GroupText: case PayloadType.GroupText:
return `Encrypted message content (${byteLength} bytes). Contains encrypted plaintext with this structure: return `Encrypted message content (${byteLength} bytes). Contains encrypted plaintext with this structure:
Timestamp (4 bytes) - send time as unix timestamp Timestamp (4 bytes) - send time as unix timestamp
Flags (1 byte) - room-message flags byte Flags (1 byte) - channel-message flags byte
Message (remaining bytes) - UTF-8 room message text`; Message (remaining bytes) - UTF-8 channel message text`;
case PayloadType.TextMessage: case PayloadType.TextMessage:
return `Encrypted message data (${byteLength} bytes). Contains encrypted plaintext with this structure: return `Encrypted message data (${byteLength} bytes). Contains encrypted plaintext with this structure:
Timestamp (4 bytes) - send time as unix timestamp Timestamp (4 bytes) - send time as unix timestamp
+6 -6
View File
@@ -7,7 +7,7 @@ import { createChannel, getChannels, getMessages } from '../helpers/api';
* Timeout is 3 minutes to allow for intermittent traffic. * Timeout is 3 minutes to allow for intermittent traffic.
*/ */
const ROOMS = [ const CHANNELS = [
'#flightless', '#bot', '#snoco', '#skagit', '#edmonds', '#bachelorette', '#flightless', '#bot', '#snoco', '#skagit', '#edmonds', '#bachelorette',
'#emergency', '#furry', '#public', '#puppy', '#foobar', '#capitolhill', '#emergency', '#furry', '#public', '#puppy', '#foobar', '#capitolhill',
'#hamradio', '#icewatch', '#saucefamily', '#scvsar', '#startrek', '#metalmusic', '#hamradio', '#icewatch', '#saucefamily', '#scvsar', '#startrek', '#metalmusic',
@@ -39,14 +39,14 @@ test.describe('Incoming mesh messages', () => {
test.setTimeout(180_000); test.setTimeout(180_000);
test.beforeAll(async () => { test.beforeAll(async () => {
// Ensure all rooms exist — create any that are missing // Ensure all channels exist — create any that are missing
const existing = await getChannels(); const existing = await getChannels();
const existingNames = new Set(existing.map((c) => c.name)); const existingNames = new Set(existing.map((c) => c.name));
for (const room of ROOMS) { for (const channel of CHANNELS) {
if (!existingNames.has(room)) { if (!existingNames.has(channel)) {
try { try {
await createChannel(room); await createChannel(channel);
} catch { } catch {
// May already exist from a concurrent creation, ignore // May already exist from a concurrent creation, ignore
} }
@@ -54,7 +54,7 @@ test.describe('Incoming mesh messages', () => {
} }
}); });
test('receive an incoming message in any room', { tag: '@mesh-traffic' }, async ({ page }) => { test('receive an incoming message in any channel', { tag: '@mesh-traffic' }, async ({ page }) => {
// Nudge echo bot on #flightless — may generate an incoming packet quickly // Nudge echo bot on #flightless — may generate an incoming packet quickly
await nudgeEchoBot(); await nudgeEchoBot();