mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-06 18:01:22 +02:00
Add regional channel routing (closes #42)
This commit is contained in:
+46
-1
@@ -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}
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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">🛎</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">🌎</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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user