Add regional channel routing (closes #42)

This commit is contained in:
Jack Kingsman
2026-03-09 11:09:31 -07:00
parent 0c5b37c07c
commit 811c7e7349
18 changed files with 604 additions and 61 deletions
+46 -1
View File
@@ -69,7 +69,15 @@ import { mergeContactIntoList } from './utils/contactMerge';
import { getLocalLabel, getContrastTextColor } from './utils/localLabel';
import { cn } from '@/lib/utils';
import type { SearchNavigateTarget } from './components/SearchView';
import type { Contact, Conversation, HealthStatus, Message, MessagePath, RawPacket } from './types';
import type {
Channel,
Contact,
Conversation,
HealthStatus,
Message,
MessagePath,
RawPacket,
} from './types';
const MAX_RAW_PACKETS = 500;
@@ -225,6 +233,21 @@ export function App() {
return contact?.type === CONTACT_TYPE_REPEATER;
}, [activeConversation, contacts]);
const mergeChannelIntoList = useCallback(
(updated: Channel) => {
setChannels((prev) => {
const existingIndex = prev.findIndex((channel) => channel.key === updated.key);
if (existingIndex === -1) {
return [...prev, updated].sort((a, b) => a.name.localeCompare(b.name));
}
const next = [...prev];
next[existingIndex] = updated;
return next;
});
},
[setChannels]
);
// WebSocket handlers - memoized to prevent reconnection loops
const wsHandlers = useMemo(
() => ({
@@ -353,6 +376,9 @@ export function App() {
onContact: (contact: Contact) => {
setContacts((prev) => mergeContactIntoList(prev, contact));
},
onChannel: (channel: Channel) => {
mergeChannelIntoList(channel);
},
onContactDeleted: (publicKey: string) => {
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
messageCache.remove(publicKey);
@@ -386,6 +412,7 @@ export function App() {
updateMessageAck,
checkMention,
fetchConfig,
mergeChannelIntoList,
prevHealthRef,
setHealth,
activeConversationRef,
@@ -467,6 +494,23 @@ export function App() {
[]
);
const handleSetChannelFloodScopeOverride = useCallback(
async (channelKey: string, floodScopeOverride: string) => {
try {
const updated = await api.setChannelFloodScopeOverride(channelKey, floodScopeOverride);
mergeChannelIntoList(updated);
toast.success(
updated.flood_scope_override ? 'Regional override saved' : 'Regional override cleared'
);
} catch (err) {
toast.error('Failed to update regional override', {
description: err instanceof Error ? err.message : 'Unknown error',
});
}
},
[mergeChannelIntoList]
);
// Handle sender click to add mention
const handleSenderClick = useCallback((sender: string) => {
messageInputRef.current?.appendText(`@[${sender}] `);
@@ -769,6 +813,7 @@ export function App() {
favorites={favorites}
onTrace={handleTrace}
onToggleFavorite={handleToggleFavorite}
onSetChannelFloodScopeOverride={handleSetChannelFloodScopeOverride}
onDeleteChannel={handleDeleteChannel}
onDeleteContact={handleDeleteContact}
onOpenContactInfo={handleOpenContactInfo}
+5
View File
@@ -157,6 +157,11 @@ export const api = {
fetchJson<{ status: string; key: string }>(`/channels/${key}/mark-read`, {
method: 'POST',
}),
setChannelFloodScopeOverride: (key: string, floodScopeOverride: string) =>
fetchJson<Channel>(`/channels/${key}/flood-scope-override`, {
method: 'POST',
body: JSON.stringify({ flood_scope_override: floodScopeOverride }),
}),
// Messages
getMessages: (
+34 -6
View File
@@ -14,6 +14,7 @@ interface ChatHeaderProps {
favorites: Favorite[];
onTrace: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
onDeleteChannel: (key: string) => void;
onDeleteContact: (publicKey: string) => void;
onOpenContactInfo?: (publicKey: string) => void;
@@ -28,6 +29,7 @@ export function ChatHeader({
favorites,
onTrace,
onToggleFavorite,
onSetChannelFloodScopeOverride,
onDeleteChannel,
onDeleteContact,
onOpenContactInfo,
@@ -39,12 +41,26 @@ export function ChatHeader({
setShowKey(false);
}, [conversation.id]);
const isPrivateChannel =
conversation.type === 'channel' && !channels.find((c) => c.key === conversation.id)?.is_hashtag;
const activeChannel =
conversation.type === 'channel'
? channels.find((channel) => channel.key === conversation.id)
: undefined;
const isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag;
const titleClickable =
(conversation.type === 'contact' && onOpenContactInfo) ||
(conversation.type === 'channel' && onOpenChannelInfo);
const handleEditFloodScopeOverride = () => {
if (conversation.type !== 'channel' || !onSetChannelFloodScopeOverride) return;
const nextValue = window.prompt(
'Enter regional override flood scope for this room. This temporarily changes the radio flood scope before send and restores it after, which significantly slows room sends. Leave blank to clear.',
activeChannel?.flood_scope_override ?? ''
);
if (nextValue === null) return;
onSetChannelFloodScopeOverride(conversation.id, nextValue);
};
return (
<header className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
@@ -87,7 +103,7 @@ export function ChatHeader({
>
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
channels.find((c) => c.key === conversation.id)?.is_hashtag
activeChannel?.is_hashtag
? '#'
: ''}
{conversation.name}
@@ -122,6 +138,11 @@ export function ChatHeader({
{conversation.type === 'channel' ? conversation.id.toLowerCase() : conversation.id}
</span>
)}
{conversation.type === 'channel' && activeChannel?.flood_scope_override && (
<span className="basis-full sm:basis-auto text-[11px] text-amber-700 dark:text-amber-300 truncate">
Regional override active: {activeChannel.flood_scope_override}
</span>
)}
{conversation.type === 'contact' &&
(() => {
const contact = contacts.find((c) => c.public_key === conversation.id);
@@ -136,7 +157,6 @@ export function ChatHeader({
})()}
</span>
<div className="flex items-center gap-0.5 flex-shrink-0">
{/* Direct trace button (contacts only) */}
{conversation.type === 'contact' && (
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@@ -147,7 +167,16 @@ export function ChatHeader({
<span aria-hidden="true">&#x1F6CE;</span>
</button>
)}
{/* Favorite button */}
{conversation.type === 'channel' && onSetChannelFloodScopeOverride && (
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={handleEditFloodScopeOverride}
title="Set regional override"
aria-label="Set regional override"
>
<span aria-hidden="true">&#127758;</span>
</button>
)}
{(conversation.type === 'channel' || conversation.type === 'contact') && (
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@@ -172,7 +201,6 @@ export function ChatHeader({
)}
</button>
)}
{/* Delete button */}
{!(conversation.type === 'channel' && conversation.name === 'Public') && (
<button
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@@ -16,6 +16,7 @@ const baseProps = {
favorites: [] as Favorite[],
onTrace: noop,
onToggleFavorite: noop,
onSetChannelFloodScopeOverride: noop,
onDeleteChannel: noop,
onDeleteContact: noop,
};
@@ -105,4 +106,40 @@ describe('ChatHeader key visibility', () => {
expect(writeText).toHaveBeenCalledWith(key);
});
it('shows active regional override banner for channels', () => {
const key = 'AB'.repeat(16);
const channel = {
...makeChannel(key, '#flightless', true),
flood_scope_override: '#Esperance',
};
const conversation: Conversation = { type: 'channel', id: key, name: '#flightless' };
render(<ChatHeader {...baseProps} conversation={conversation} channels={[channel]} />);
expect(screen.getByText('Regional override active: #Esperance')).toBeInTheDocument();
});
it('prompts for regional override when globe button is clicked', () => {
const key = 'CD'.repeat(16);
const channel = makeChannel(key, '#flightless', true);
const conversation: Conversation = { type: 'channel', id: key, name: '#flightless' };
const onSetChannelFloodScopeOverride = vi.fn();
const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue('#Esperance');
render(
<ChatHeader
{...baseProps}
conversation={conversation}
channels={[channel]}
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
/>
);
fireEvent.click(screen.getByTitle('Set regional override'));
expect(promptSpy).toHaveBeenCalled();
expect(onSetChannelFloodScopeOverride).toHaveBeenCalledWith(key, '#Esperance');
promptSpy.mockRestore();
});
});
+1
View File
@@ -130,6 +130,7 @@ export interface Channel {
name: string;
is_hashtag: boolean;
on_radio: boolean;
flood_scope_override?: string | null;
last_read_at: number | null;
}
+5 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useCallback } from 'react';
import type { HealthStatus, Contact, Message, MessagePath, RawPacket } from './types';
import type { Channel, HealthStatus, Contact, Message, MessagePath, RawPacket } from './types';
interface WebSocketMessage {
type: string;
@@ -21,6 +21,7 @@ interface UseWebSocketOptions {
onMessage?: (message: Message) => void;
onContact?: (contact: Contact) => void;
onContactDeleted?: (publicKey: string) => void;
onChannel?: (channel: Channel) => void;
onChannelDeleted?: (key: string) => void;
onRawPacket?: (packet: RawPacket) => void;
onMessageAcked?: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
@@ -105,6 +106,9 @@ export function useWebSocket(options: UseWebSocketOptions) {
case 'contact':
handlers.onContact?.(msg.data as Contact);
break;
case 'channel':
handlers.onChannel?.(msg.data as Channel);
break;
case 'contact_deleted':
handlers.onContactDeleted?.((msg.data as { public_key: string }).public_key);
break;